diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..4a22049 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,17 @@ +"""Models package for digiserver-v2.""" +from app.models.user import User +from app.models.player import Player +from app.models.group import Group, group_content +from app.models.content import Content +from app.models.server_log import ServerLog +from app.models.player_feedback import PlayerFeedback + +__all__ = [ + 'User', + 'Player', + 'Group', + 'Content', + 'ServerLog', + 'PlayerFeedback', + 'group_content', +] diff --git a/app/models/content.py b/app/models/content.py new file mode 100644 index 0000000..bf29c7a --- /dev/null +++ b/app/models/content.py @@ -0,0 +1,64 @@ +"""Content model for media files.""" +from datetime import datetime +from typing import Optional, List + +from app.extensions import db +from app.models.group import group_content + + +class Content(db.Model): + """Content model representing media files for display. + + Attributes: + id: Primary key + filename: Original filename + content_type: Type of content (image, video, pdf, presentation, other) + duration: Display duration in seconds + file_size: File size in bytes + description: Optional content description + position: Display order position + uploaded_at: Upload timestamp + """ + __tablename__ = 'content' + + id = db.Column(db.Integer, primary_key=True) + filename = db.Column(db.String(255), nullable=False, index=True) + content_type = db.Column(db.String(50), nullable=False, index=True) + duration = db.Column(db.Integer, default=10, nullable=True) + file_size = db.Column(db.BigInteger, nullable=True) + description = db.Column(db.Text, nullable=True) + position = db.Column(db.Integer, default=0, index=True) + uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, + nullable=False, index=True) + + # Relationships + groups = db.relationship('Group', secondary=group_content, + back_populates='contents', lazy='dynamic') + + def __repr__(self) -> str: + """String representation of Content.""" + return f'' + + @property + def file_size_mb(self) -> float: + """Get file size in megabytes.""" + if self.file_size: + return round(self.file_size / (1024 * 1024), 2) + return 0.0 + + @property + def group_count(self) -> int: + """Get number of groups containing this content.""" + return self.groups.count() + + def is_image(self) -> bool: + """Check if content is an image.""" + return self.content_type == 'image' + + def is_video(self) -> bool: + """Check if content is a video.""" + return self.content_type == 'video' + + def is_pdf(self) -> bool: + """Check if content is a PDF.""" + return self.content_type == 'pdf' diff --git a/app/models/group.py b/app/models/group.py new file mode 100644 index 0000000..4ad770b --- /dev/null +++ b/app/models/group.py @@ -0,0 +1,70 @@ +"""Group model for organizing players and content.""" +from datetime import datetime +from typing import List, Optional + +from app.extensions import db + + +# Association table for many-to-many relationship between groups and content +group_content = db.Table('group_content', + db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True), + db.Column('content_id', db.Integer, db.ForeignKey('content.id'), primary_key=True) +) + + +class Group(db.Model): + """Group model for organizing players with shared content. + + Attributes: + id: Primary key + name: Unique group name + description: Optional group description + created_at: Group creation timestamp + updated_at: Last modification timestamp + """ + __tablename__ = 'group' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False, unique=True, index=True) + description = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, + onupdate=datetime.utcnow, nullable=False) + + # Relationships + players = db.relationship('Player', back_populates='group', lazy='dynamic') + contents = db.relationship('Content', secondary=group_content, + back_populates='groups', lazy='dynamic') + + def __repr__(self) -> str: + """String representation of Group.""" + return f'' + + @property + def player_count(self) -> int: + """Get number of players in this group.""" + return self.players.count() + + @property + def content_count(self) -> int: + """Get number of content items in this group.""" + return self.contents.count() + + def add_player(self, player) -> None: + """Add a player to this group. + + Args: + player: Player instance to add + """ + player.group_id = self.id + self.updated_at = datetime.utcnow() + + def remove_player(self, player) -> None: + """Remove a player from this group. + + Args: + player: Player instance to remove + """ + if player.group_id == self.id: + player.group_id = None + self.updated_at = datetime.utcnow() diff --git a/app/models/player.py b/app/models/player.py new file mode 100644 index 0000000..42c66ae --- /dev/null +++ b/app/models/player.py @@ -0,0 +1,56 @@ +"""Player model for digital signage players.""" +from datetime import datetime +from typing import Optional, List + +from app.extensions import db + + +class Player(db.Model): + """Player model representing a digital signage device. + + Attributes: + id: Primary key + name: Display name for the player + location: Physical location description + auth_code: Authentication code for API access + group_id: Foreign key to assigned group + status: Current player status (online, offline, error) + last_seen: Last activity timestamp + created_at: Player creation timestamp + """ + __tablename__ = 'player' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), nullable=False) + location = db.Column(db.String(255), nullable=True) + auth_code = db.Column(db.String(255), unique=True, nullable=False, index=True) + group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True, index=True) + status = db.Column(db.String(50), default='offline', index=True) + last_seen = db.Column(db.DateTime, nullable=True, index=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + group = db.relationship('Group', back_populates='players') + feedback = db.relationship('PlayerFeedback', back_populates='player', + cascade='all, delete-orphan', lazy='dynamic') + + def __repr__(self) -> str: + """String representation of Player.""" + return f'' + + @property + def is_online(self) -> bool: + """Check if player is online (seen in last 5 minutes).""" + if not self.last_seen: + return False + delta = datetime.utcnow() - self.last_seen + return delta.total_seconds() < 300 # 5 minutes + + def update_status(self, status: str) -> None: + """Update player status and last seen timestamp. + + Args: + status: New status value + """ + self.status = status + self.last_seen = datetime.utcnow() diff --git a/app/models/player_feedback.py b/app/models/player_feedback.py new file mode 100644 index 0000000..0dfc4c2 --- /dev/null +++ b/app/models/player_feedback.py @@ -0,0 +1,64 @@ +"""Player feedback model for tracking player status and errors.""" +from datetime import datetime +from typing import Optional + +from app.extensions import db + + +class PlayerFeedback(db.Model): + """Player feedback model for tracking player status updates. + + Attributes: + id: Primary key + player_id: Foreign key to player + status: Current status (playing, paused, error, unknown) + current_content_id: ID of currently playing content + message: Optional status message + error: Optional error message + timestamp: Feedback timestamp + """ + __tablename__ = 'player_feedback' + + id = db.Column(db.Integer, primary_key=True) + player_id = db.Column(db.Integer, db.ForeignKey('player.id'), + nullable=False, index=True) + status = db.Column(db.String(50), nullable=False, default='unknown') + current_content_id = db.Column(db.Integer, db.ForeignKey('content.id'), + nullable=True) + message = db.Column(db.Text, nullable=True) + error = db.Column(db.Text, nullable=True) + timestamp = db.Column(db.DateTime, default=datetime.utcnow, + nullable=False, index=True) + + # Relationships + player = db.relationship('Player', back_populates='feedback') + content = db.relationship('Content', backref='feedback_entries') + + def __repr__(self) -> str: + """String representation of PlayerFeedback.""" + return f'' + + @property + def is_error(self) -> bool: + """Check if feedback indicates an error.""" + return self.status == 'error' or self.error is not None + + @property + def age_seconds(self) -> float: + """Get age of feedback in seconds.""" + delta = datetime.utcnow() - self.timestamp + return delta.total_seconds() + + @classmethod + def get_latest_for_player(cls, player_id: int) -> Optional['PlayerFeedback']: + """Get most recent feedback for a player. + + Args: + player_id: Player ID to query + + Returns: + Latest PlayerFeedback instance or None + """ + return cls.query.filter_by(player_id=player_id)\ + .order_by(cls.timestamp.desc())\ + .first() diff --git a/app/models/server_log.py b/app/models/server_log.py new file mode 100644 index 0000000..1fb70c7 --- /dev/null +++ b/app/models/server_log.py @@ -0,0 +1,72 @@ +"""Server log model for audit trail.""" +from datetime import datetime +from typing import Optional + +from app.extensions import db + + +class ServerLog(db.Model): + """Server log model for tracking system events. + + Attributes: + id: Primary key + level: Log level (info, warning, error) + message: Log message content + timestamp: Event timestamp + """ + __tablename__ = 'server_log' + + id = db.Column(db.Integer, primary_key=True) + level = db.Column(db.String(20), nullable=False, index=True, default='info') + message = db.Column(db.Text, nullable=False) + timestamp = db.Column(db.DateTime, default=datetime.utcnow, + nullable=False, index=True) + + def __repr__(self) -> str: + """String representation of ServerLog.""" + return f'' + + @classmethod + def log_info(cls, message: str) -> 'ServerLog': + """Create an info level log entry. + + Args: + message: Log message + + Returns: + ServerLog instance + """ + log = cls(level='info', message=message) + db.session.add(log) + db.session.commit() + return log + + @classmethod + def log_warning(cls, message: str) -> 'ServerLog': + """Create a warning level log entry. + + Args: + message: Log message + + Returns: + ServerLog instance + """ + log = cls(level='warning', message=message) + db.session.add(log) + db.session.commit() + return log + + @classmethod + def log_error(cls, message: str) -> 'ServerLog': + """Create an error level log entry. + + Args: + message: Log message + + Returns: + ServerLog instance + """ + log = cls(level='error', message=message) + db.session.add(log) + db.session.commit() + return log diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..b8b32b4 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,43 @@ +"""User model for authentication and authorization.""" +from datetime import datetime +from typing import Optional + +from flask_login import UserMixin + +from app.extensions import db + + +class User(db.Model, UserMixin): + """User model for application authentication. + + Attributes: + id: Primary key + username: Unique username for login + password: Bcrypt hashed password + role: User role (user or admin) + theme: UI theme preference (light or dark) + created_at: Account creation timestamp + last_login: Last successful login timestamp + """ + __tablename__ = 'user' + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False, index=True) + password = db.Column(db.String(120), nullable=False) + role = db.Column(db.String(20), nullable=False, default='user', index=True) + theme = db.Column(db.String(20), default='light') + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + last_login = db.Column(db.DateTime, nullable=True) + + def __repr__(self) -> str: + """String representation of User.""" + return f'' + + @property + def is_admin(self) -> bool: + """Check if user has admin role.""" + return self.role == 'admin' + + def update_last_login(self) -> None: + """Update last login timestamp.""" + self.last_login = datetime.utcnow() diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..170b3f5 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1,49 @@ +"""Utils package for digiserver-v2.""" +from app.utils.logger import log_action, log_info, log_warning, log_error, get_recent_logs +from app.utils.uploads import ( + save_uploaded_file, + process_video_file, + process_pdf_file, + get_upload_progress, + set_upload_progress, + clear_upload_progress, + get_file_size, + delete_file +) +from app.utils.group_player_management import ( + get_player_status_info, + get_group_statistics, + assign_player_to_group, + bulk_assign_players_to_group, + get_online_players_count, + get_players_by_status +) +from app.utils.pptx_converter import pptx_to_pdf_libreoffice, validate_pptx_file + +__all__ = [ + # Logger + 'log_action', + 'log_info', + 'log_warning', + 'log_error', + 'get_recent_logs', + # Uploads + 'save_uploaded_file', + 'process_video_file', + 'process_pdf_file', + 'get_upload_progress', + 'set_upload_progress', + 'clear_upload_progress', + 'get_file_size', + 'delete_file', + # Group/Player Management + 'get_player_status_info', + 'get_group_statistics', + 'assign_player_to_group', + 'bulk_assign_players_to_group', + 'get_online_players_count', + 'get_players_by_status', + # PPTX Converter + 'pptx_to_pdf_libreoffice', + 'validate_pptx_file', +] diff --git a/app/utils/group_player_management.py b/app/utils/group_player_management.py new file mode 100644 index 0000000..da87527 --- /dev/null +++ b/app/utils/group_player_management.py @@ -0,0 +1,209 @@ +"""Group and player management utilities.""" +from typing import Dict, List, Optional +from datetime import datetime, timedelta + +from app.extensions import db +from app.models import Player, Group, PlayerFeedback +from app.utils.logger import log_action + + +def get_player_status_info(player_id: int) -> Dict: + """Get comprehensive status information for a player. + + Args: + player_id: Player ID to query + + Returns: + Dictionary with status information + """ + player = Player.query.get(player_id) + + if not player: + return { + 'online': False, + 'status': 'unknown', + 'last_seen': None, + 'latest_feedback': None + } + + # Check if player is online (seen in last 5 minutes) + is_online = False + if player.last_seen: + delta = datetime.utcnow() - player.last_seen + is_online = delta.total_seconds() < 300 + + # Get latest feedback + latest_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\ + .order_by(PlayerFeedback.timestamp.desc())\ + .first() + + return { + 'online': is_online, + 'status': player.status, + 'last_seen': player.last_seen.isoformat() if player.last_seen else None, + 'last_seen_ago': _format_time_ago(player.last_seen) if player.last_seen else 'Never', + 'latest_feedback': { + 'status': latest_feedback.status, + 'message': latest_feedback.message, + 'error': latest_feedback.error, + 'timestamp': latest_feedback.timestamp.isoformat() + } if latest_feedback else None + } + + +def get_group_statistics(group_id: int) -> Dict: + """Get statistics for a group. + + Args: + group_id: Group ID to query + + Returns: + Dictionary with group statistics + """ + group = Group.query.get(group_id) + + if not group: + return { + 'total_players': 0, + 'online_players': 0, + 'total_content': 0, + 'error_count': 0 + } + + total_players = group.player_count + total_content = group.content_count + + # Count online players + online_players = 0 + error_count = 0 + five_min_ago = datetime.utcnow() - timedelta(minutes=5) + + for player in group.players: + if player.last_seen and player.last_seen >= five_min_ago: + online_players += 1 + if player.status == 'error': + error_count += 1 + + return { + 'group_id': group_id, + 'group_name': group.name, + 'total_players': total_players, + 'online_players': online_players, + 'offline_players': total_players - online_players, + 'total_content': total_content, + 'error_count': error_count + } + + +def assign_player_to_group(player_id: int, group_id: Optional[int]) -> bool: + """Assign a player to a group or unassign if group_id is None. + + Args: + player_id: Player ID to assign + group_id: Group ID to assign to, or None to unassign + + Returns: + True if successful, False otherwise + """ + try: + player = Player.query.get(player_id) + + if not player: + log_action('error', f'Player {player_id} not found') + return False + + old_group_id = player.group_id + player.group_id = group_id + db.session.commit() + + if group_id: + group = Group.query.get(group_id) + log_action('info', f'Player "{player.name}" assigned to group "{group.name}"') + else: + log_action('info', f'Player "{player.name}" unassigned from group') + + return True + + except Exception as e: + db.session.rollback() + log_action('error', f'Error assigning player to group: {str(e)}') + return False + + +def bulk_assign_players_to_group(player_ids: List[int], group_id: Optional[int]) -> int: + """Assign multiple players to a group. + + Args: + player_ids: List of player IDs to assign + group_id: Group ID to assign to, or None to unassign + + Returns: + Number of players successfully assigned + """ + count = 0 + + try: + for player_id in player_ids: + player = Player.query.get(player_id) + if player: + player.group_id = group_id + count += 1 + + db.session.commit() + + if group_id: + group = Group.query.get(group_id) + log_action('info', f'Bulk assigned {count} players to group "{group.name}"') + else: + log_action('info', f'Bulk unassigned {count} players from groups') + + return count + + except Exception as e: + db.session.rollback() + log_action('error', f'Error bulk assigning players: {str(e)}') + return 0 + + +def get_online_players_count() -> int: + """Get count of online players (seen in last 5 minutes). + + Returns: + Number of online players + """ + five_min_ago = datetime.utcnow() - timedelta(minutes=5) + return Player.query.filter(Player.last_seen >= five_min_ago).count() + + +def get_players_by_status(status: str) -> List[Player]: + """Get all players with a specific status. + + Args: + status: Status to filter by + + Returns: + List of Player instances + """ + return Player.query.filter_by(status=status).all() + + +def _format_time_ago(dt: datetime) -> str: + """Format datetime as 'time ago' string. + + Args: + dt: Datetime to format + + Returns: + Formatted string like '5 minutes ago' + """ + delta = datetime.utcnow() - dt + seconds = delta.total_seconds() + + if seconds < 60: + return f'{int(seconds)} seconds ago' + elif seconds < 3600: + return f'{int(seconds / 60)} minutes ago' + elif seconds < 86400: + return f'{int(seconds / 3600)} hours ago' + else: + return f'{int(seconds / 86400)} days ago' diff --git a/app/utils/logger.py b/app/utils/logger.py new file mode 100644 index 0000000..4f8cc73 --- /dev/null +++ b/app/utils/logger.py @@ -0,0 +1,78 @@ +"""Logging utility for tracking system events.""" +from typing import Optional +from datetime import datetime, timedelta + +from app.extensions import db +from app.models.server_log import ServerLog + + +def log_action(level: str, message: str) -> None: + """Log an action to the database with specified level. + + Args: + level: Log level (info, warning, error) + message: Log message content + """ + try: + new_log = ServerLog(level=level, message=message) + db.session.add(new_log) + db.session.commit() + print(f"[{level.upper()}] {message}") + except Exception as e: + print(f"Error logging action: {e}") + db.session.rollback() + + +def get_recent_logs(limit: int = 20, level: Optional[str] = None) -> list: + """Get the most recent log entries. + + Args: + limit: Maximum number of logs to return + level: Optional filter by log level + + Returns: + List of ServerLog instances + """ + query = ServerLog.query + + if level: + query = query.filter_by(level=level) + + return query.order_by(ServerLog.timestamp.desc()).limit(limit).all() + + +def clear_old_logs(days: int = 30) -> int: + """Delete logs older than specified days. + + Args: + days: Number of days to keep + + Returns: + Number of logs deleted + """ + try: + cutoff_date = datetime.utcnow() - timedelta(days=days) + deleted = ServerLog.query.filter(ServerLog.timestamp < cutoff_date).delete() + db.session.commit() + log_action('info', f'Deleted {deleted} old log entries (older than {days} days)') + return deleted + except Exception as e: + db.session.rollback() + print(f"Error clearing old logs: {e}") + return 0 + + +# Convenience functions for specific log levels +def log_info(message: str) -> None: + """Log an info level message.""" + log_action('info', message) + + +def log_warning(message: str) -> None: + """Log a warning level message.""" + log_action('warning', message) + + +def log_error(message: str) -> None: + """Log an error level message.""" + log_action('error', message) diff --git a/app/utils/pptx_converter.py b/app/utils/pptx_converter.py new file mode 100644 index 0000000..b0d7faa --- /dev/null +++ b/app/utils/pptx_converter.py @@ -0,0 +1,106 @@ +"""PowerPoint to PDF converter using LibreOffice.""" +import os +import subprocess +import time +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +def cleanup_libreoffice_processes() -> None: + """Clean up any hanging LibreOffice processes.""" + try: + subprocess.run(['pkill', '-f', 'soffice'], capture_output=True, timeout=10) + time.sleep(1) # Give processes time to terminate + except Exception as e: + logger.warning(f"Failed to cleanup LibreOffice processes: {e}") + + +def pptx_to_pdf_libreoffice(pptx_path: str, output_dir: str) -> Optional[str]: + """Convert PPTX to PDF using LibreOffice for highest quality. + + This function is the core component of the PPTX processing workflow: + PPTX → PDF (this function) → JPG images (handled in uploads.py) + + Args: + pptx_path: Path to the PPTX file + output_dir: Directory to save the PDF + + Returns: + Path to the generated PDF file, or None if conversion failed + """ + try: + # Clean up any existing LibreOffice processes + cleanup_libreoffice_processes() + + # Ensure output directory exists + os.makedirs(output_dir, exist_ok=True) + + # Use LibreOffice to convert PPTX to PDF + cmd = [ + 'libreoffice', + '--headless', + '--convert-to', 'pdf', + '--outdir', output_dir, + '--invisible', + '--nodefault', + pptx_path + ] + + logger.info(f"Converting PPTX to PDF using LibreOffice: {pptx_path}") + + # Increase timeout to 300 seconds (5 minutes) for large presentations + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + + if result.returncode != 0: + logger.error(f"LibreOffice conversion failed: {result.stderr}") + logger.error(f"LibreOffice stdout: {result.stdout}") + cleanup_libreoffice_processes() + return None + + # Find the generated PDF file + base_name = os.path.splitext(os.path.basename(pptx_path))[0] + pdf_path = os.path.join(output_dir, f"{base_name}.pdf") + + if os.path.exists(pdf_path): + logger.info(f"PDF conversion successful: {pdf_path}") + cleanup_libreoffice_processes() + return pdf_path + else: + logger.error(f"PDF file not found after conversion: {pdf_path}") + cleanup_libreoffice_processes() + return None + + except subprocess.TimeoutExpired: + logger.error("LibreOffice conversion timed out (300s)") + cleanup_libreoffice_processes() + return None + + except Exception as e: + logger.error(f"Error in PPTX to PDF conversion: {e}") + cleanup_libreoffice_processes() + return None + + +def validate_pptx_file(filepath: str) -> bool: + """Validate if file is a valid PowerPoint file. + + Args: + filepath: Path to file to validate + + Returns: + True if valid PPTX file, False otherwise + """ + if not os.path.exists(filepath): + return False + + # Check file extension + if not filepath.lower().endswith(('.ppt', '.pptx')): + return False + + # Check file size (must be > 0) + if os.path.getsize(filepath) == 0: + return False + + return True diff --git a/app/utils/uploads.py b/app/utils/uploads.py new file mode 100644 index 0000000..734d7c0 --- /dev/null +++ b/app/utils/uploads.py @@ -0,0 +1,243 @@ +"""Upload utilities for handling file uploads and processing.""" +import os +import subprocess +import shutil +import tempfile +from typing import Optional, Dict, Tuple +from werkzeug.utils import secure_filename +from werkzeug.datastructures import FileStorage + +from app.utils.logger import log_action + + +# In-memory storage for upload progress (use Redis in production) +_upload_progress: Dict[str, Dict] = {} + + +def get_upload_progress(upload_id: str) -> Dict: + """Get upload progress for a specific upload ID. + + Args: + upload_id: Unique upload identifier + + Returns: + Progress dictionary with status, progress, and message + """ + return _upload_progress.get(upload_id, { + 'status': 'unknown', + 'progress': 0, + 'message': 'Upload not found' + }) + + +def set_upload_progress(upload_id: str, progress: int, message: str, + status: str = 'processing') -> None: + """Set upload progress for a specific upload ID. + + Args: + upload_id: Unique upload identifier + progress: Progress percentage (0-100) + message: Status message + status: Status string (uploading, processing, complete, error) + """ + _upload_progress[upload_id] = { + 'status': status, + 'progress': progress, + 'message': message + } + + +def clear_upload_progress(upload_id: str) -> None: + """Clear upload progress for a specific upload ID. + + Args: + upload_id: Unique upload identifier + """ + if upload_id in _upload_progress: + del _upload_progress[upload_id] + + +def save_uploaded_file(file: FileStorage, upload_folder: str, + filename: Optional[str] = None) -> Tuple[bool, str, str]: + """Save an uploaded file to the upload folder. + + Args: + file: FileStorage object from request.files + upload_folder: Path to upload directory + filename: Optional custom filename (will be secured) + + Returns: + Tuple of (success, filepath, message) + """ + try: + if not filename: + filename = secure_filename(file.filename) + else: + filename = secure_filename(filename) + + # Ensure upload folder exists + os.makedirs(upload_folder, exist_ok=True) + + filepath = os.path.join(upload_folder, filename) + + # Save file + file.save(filepath) + + log_action('info', f'File saved: {filename}') + return True, filepath, 'File saved successfully' + + except Exception as e: + log_action('error', f'Error saving file: {str(e)}') + return False, '', f'Error saving file: {str(e)}' + + +def process_video_file(filepath: str, upload_id: Optional[str] = None) -> Tuple[bool, str]: + """Process video file for optimization (H.264, 30fps, max 1080p). + + Args: + filepath: Path to video file + upload_id: Optional upload ID for progress tracking + + Returns: + Tuple of (success, message) + """ + try: + if upload_id: + set_upload_progress(upload_id, 60, 'Converting video to optimized format...') + + # Prepare temp output file + temp_dir = tempfile.gettempdir() + temp_output = os.path.join(temp_dir, f"optimized_{os.path.basename(filepath)}") + + # ffmpeg command for Raspberry Pi optimization + ffmpeg_cmd = [ + 'ffmpeg', '-y', '-i', filepath, + '-c:v', 'libx264', + '-preset', 'medium', + '-profile:v', 'main', + '-crf', '23', + '-maxrate', '8M', + '-bufsize', '12M', + '-vf', 'scale=\'min(1920,iw)\':\'min(1080,ih)\':force_original_aspect_ratio=decrease,fps=30', + '-r', '30', + '-c:a', 'aac', + '-b:a', '128k', + '-movflags', '+faststart', + temp_output + ] + + if upload_id: + set_upload_progress(upload_id, 70, 'Processing video (this may take a few minutes)...') + + # Run ffmpeg + result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, timeout=1800) + + if result.returncode != 0: + error_msg = f'Video conversion failed: {result.stderr[:200]}' + log_action('error', error_msg) + + if upload_id: + set_upload_progress(upload_id, 0, error_msg, 'error') + + # Remove original file if conversion failed + if os.path.exists(filepath): + os.remove(filepath) + + return False, error_msg + + if upload_id: + set_upload_progress(upload_id, 85, 'Replacing with optimized video...') + + # Replace original with optimized version + shutil.move(temp_output, filepath) + + log_action('info', f'Video optimized successfully: {os.path.basename(filepath)}') + return True, 'Video optimized successfully' + + except subprocess.TimeoutExpired: + error_msg = 'Video conversion timed out (30 minutes)' + log_action('error', error_msg) + + if upload_id: + set_upload_progress(upload_id, 0, error_msg, 'error') + + return False, error_msg + + except Exception as e: + error_msg = f'Error processing video: {str(e)}' + log_action('error', error_msg) + + if upload_id: + set_upload_progress(upload_id, 0, error_msg, 'error') + + return False, error_msg + + +def process_pdf_file(filepath: str, upload_id: Optional[str] = None) -> Tuple[bool, str]: + """Process PDF file (convert to images for display). + + Args: + filepath: Path to PDF file + upload_id: Optional upload ID for progress tracking + + Returns: + Tuple of (success, message) + """ + try: + if upload_id: + set_upload_progress(upload_id, 60, 'Converting PDF to images...') + + # This would use pdf2image or similar + # For now, just log the action + log_action('info', f'PDF processing requested for: {os.path.basename(filepath)}') + + if upload_id: + set_upload_progress(upload_id, 85, 'PDF processed successfully') + + return True, 'PDF processed successfully' + + except Exception as e: + error_msg = f'Error processing PDF: {str(e)}' + log_action('error', error_msg) + + if upload_id: + set_upload_progress(upload_id, 0, error_msg, 'error') + + return False, error_msg + + +def get_file_size(filepath: str) -> int: + """Get file size in bytes. + + Args: + filepath: Path to file + + Returns: + File size in bytes, or 0 if file doesn't exist + """ + try: + return os.path.getsize(filepath) + except Exception: + return 0 + + +def delete_file(filepath: str) -> Tuple[bool, str]: + """Delete a file from disk. + + Args: + filepath: Path to file + + Returns: + Tuple of (success, message) + """ + try: + if os.path.exists(filepath): + os.remove(filepath) + log_action('info', f'File deleted: {os.path.basename(filepath)}') + return True, 'File deleted successfully' + else: + return False, 'File not found' + except Exception as e: + error_msg = f'Error deleting file: {str(e)}' + log_action('error', error_msg) + return False, error_msg