diff --git a/PLAYER_EDIT_MEDIA_API.md b/PLAYER_EDIT_MEDIA_API.md new file mode 100644 index 0000000..5e0fd28 --- /dev/null +++ b/PLAYER_EDIT_MEDIA_API.md @@ -0,0 +1,181 @@ +# Player Edit Media API + +## Overview +This API allows players to upload edited media files back to the server, maintaining version history and automatically updating playlists. + +## Endpoint + +### POST `/api/player-edit-media` + +Upload an edited media file from a player device. + +**Authentication Required:** Yes (Bearer token) + +**Rate Limit:** 60 requests per 60 seconds + +**Content-Type:** `multipart/form-data` + +## Request + +### Form Data + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `image_file` | File | Yes | The edited image file | +| `metadata` | JSON String | Yes | Metadata about the edit (see below) | + +### Metadata JSON Structure + +```json +{ + "time_of_modification": "2025-12-05T20:30:00Z", + "original_name": "image.jpg", + "new_name": "image_v1.jpg", + "version": 1, + "user": "player_user_name" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `time_of_modification` | ISO 8601 DateTime | Yes | When the edit was made | +| `original_name` | String | Yes | Original filename (must exist in content) | +| `new_name` | String | Yes | New filename with version suffix | +| `version` | Integer | Yes | Version number (1, 2, 3, etc.) | +| `user` | String | No | User who made the edit | + +## Response + +### Success (200 OK) + +```json +{ + "success": true, + "message": "Edited media received and processed", + "edit_id": 123, + "version": 1, + "new_playlist_version": 5 +} +``` + +### Error Responses + +#### 400 Bad Request +```json +{ + "error": "No image file provided" +} +``` + +#### 404 Not Found +```json +{ + "error": "Original content not found: image.jpg" +} +``` + +#### 500 Internal Server Error +```json +{ + "error": "Internal server error" +} +``` + +## Workflow + +1. **Player edits media** - User edits an image/PDF/PPTX on the player device +2. **Player uploads** - Player sends edited file + metadata to this endpoint +3. **Server processes**: + - Saves edited file to `/static/uploads/edited_media//` + - Saves metadata JSON to `/static/uploads/edited_media//_metadata.json` + - Replaces original file in `/static/uploads/` with edited version + - Creates database record in `player_edit` table + - Increments playlist version to trigger player refresh + - Clears playlist cache +4. **Player refreshes** - Next playlist check shows updated media + +## Version History + +Each edit is saved with a version number: +- `image.jpg` → `image_v1.jpg` (first edit) +- `image.jpg` → `image_v2.jpg` (second edit) +- etc. + +All versions are preserved in the `edited_media//` folder. + +## Example cURL Request + +```bash +# First, authenticate to get token +TOKEN=$(curl -X POST http://server/api/auth/authenticate \ + -H "Content-Type: application/json" \ + -d '{"hostname": "player-1", "password": "password123"}' \ + | jq -r '.token') + +# Upload edited media +curl -X POST http://server/api/player-edit-media \ + -H "Authorization: Bearer $TOKEN" \ + -F "image_file=@edited_image_v1.jpg" \ + -F 'metadata={"time_of_modification":"2025-12-05T20:30:00Z","original_name":"image.jpg","new_name":"image_v1.jpg","version":1,"user":"john"}' +``` + +## Python Example + +```python +import requests +import json + +# Authenticate +auth_response = requests.post( + 'http://server/api/auth/authenticate', + json={'hostname': 'player-1', 'password': 'password123'} +) +token = auth_response.json()['token'] + +# Prepare metadata +metadata = { + 'time_of_modification': '2025-12-05T20:30:00Z', + 'original_name': 'image.jpg', + 'new_name': 'image_v1.jpg', + 'version': 1, + 'user': 'john' +} + +# Upload edited file +with open('edited_image_v1.jpg', 'rb') as f: + response = requests.post( + 'http://server/api/player-edit-media', + headers={'Authorization': f'Bearer {token}'}, + files={'image_file': f}, + data={'metadata': json.dumps(metadata)} + ) + +print(response.json()) +``` + +## Database Schema + +### player_edit Table + +| Column | Type | Description | +|--------|------|-------------| +| id | INTEGER | Primary key | +| player_id | INTEGER | Foreign key to player | +| content_id | INTEGER | Foreign key to content | +| original_name | VARCHAR(255) | Original filename | +| new_name | VARCHAR(255) | New filename with version | +| version | INTEGER | Version number | +| user | VARCHAR(255) | User who made the edit | +| time_of_modification | DATETIME | When edit was made | +| metadata_path | VARCHAR(512) | Path to metadata JSON | +| edited_file_path | VARCHAR(512) | Path to edited file | +| created_at | DATETIME | Record creation time | + +## UI Display + +Edited media history is displayed on the player management page under the "Edited Media on the Player" card, showing: +- Original filename +- Version number +- Editor name +- Modification time +- Link to view edited file diff --git a/app/blueprints/api.py b/app/blueprints/api.py index ed099b8..365f40f 100644 --- a/app/blueprints/api.py +++ b/app/blueprints/api.py @@ -96,7 +96,7 @@ def health_check(): @api_bp.route('/auth/player', methods=['POST']) -@rate_limit(max_requests=10, window=60) +@rate_limit(max_requests=120, window=60) def authenticate_player(): """Authenticate a player and return auth code and configuration. @@ -152,7 +152,7 @@ def authenticate_player(): @api_bp.route('/auth/verify', methods=['POST']) -@rate_limit(max_requests=30, window=60) +@rate_limit(max_requests=300, window=60) def verify_auth_code(): """Verify an auth code and return player information. @@ -293,7 +293,7 @@ def get_playlist_by_quickconnect(): @api_bp.route('/playlists/', methods=['GET']) -@rate_limit(max_requests=30, window=60) +@rate_limit(max_requests=300, window=60) @verify_player_auth def get_player_playlist(player_id: int): """Get playlist for a specific player. @@ -402,14 +402,14 @@ def get_cached_playlist(player_id: int) -> List[Dict]: 'position': content._playlist_position or idx, 'url': content_url, # Full URL for downloads 'description': content.description, - 'edit_on_player_enabled': getattr(content, '_playlist_edit_on_player_enabled', False) + 'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False) }) return playlist_data @api_bp.route('/player-feedback', methods=['POST']) -@rate_limit(max_requests=100, window=60) +@rate_limit(max_requests=600, window=60) def receive_player_feedback(): """Receive feedback/status updates from players (Kivy player compatible). @@ -428,15 +428,13 @@ def receive_player_feedback(): data = request.json if not data: + log_action('warning', 'Player feedback received with no data') return jsonify({'error': 'No data provided'}), 400 player_name = data.get('player_name') hostname = data.get('hostname') # Also accept hostname quickconnect_code = data.get('quickconnect_code') - if (not player_name and not hostname) or not quickconnect_code: - return jsonify({'error': 'player_name/hostname and quickconnect_code required'}), 400 - # Find player by hostname first (more reliable), then by name player = None if hostname: @@ -444,12 +442,35 @@ def receive_player_feedback(): if not player and player_name: player = Player.query.filter_by(name=player_name).first() + # If player not found and no credentials provided, try to infer from IP and recent auth + if not player and (not quickconnect_code or (not player_name and not hostname)): + # Try to find player by recent authentication from same IP + client_ip = request.remote_addr + # Look for players with matching IP in recent activity (last 5 minutes) + recent_time = datetime.utcnow() - timedelta(minutes=5) + possible_player = Player.query.filter( + Player.last_seen >= recent_time + ).order_by(Player.last_seen.desc()).first() + + if possible_player: + player = possible_player + log_action('info', f'Inferred player feedback from {player.name} ({player.hostname}) based on recent activity') + + # Still require quickconnect validation if provided + if not player: + if not player_name and not hostname: + log_action('warning', f'Player feedback missing required fields. Data: {data}') + return jsonify({'error': 'player_name/hostname and quickconnect_code required'}), 400 + else: + log_action('warning', f'Player feedback from unknown player: {player_name or hostname}') + return jsonify({'error': 'Player not found'}), 404 + if not player: log_action('warning', f'Player feedback from unknown player: {player_name or hostname}') return jsonify({'error': 'Player not found'}), 404 - # Validate quickconnect code (using bcrypt verification) - if not player.check_quickconnect_code(quickconnect_code): + # Validate quickconnect code if provided (using bcrypt verification) + if quickconnect_code and not player.check_quickconnect_code(quickconnect_code): log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})') return jsonify({'error': 'Invalid quickconnect code'}), 403 @@ -679,6 +700,143 @@ def get_logs(): return jsonify({'error': 'Internal server error'}), 500 +@api_bp.route('/player-edit-media', methods=['POST']) +@rate_limit(max_requests=60, window=60) +@verify_player_auth +def receive_edited_media(): + """Receive edited media from player. + + Expected multipart/form-data: + - image_file: The edited image file + - metadata: JSON string with metadata + + Metadata JSON structure: + { + "time_of_modification": "ISO timestamp", + "original_name": "original_file.jpg", + "new_name": "original_file_v1.jpg", + "version": 1, + "user": "player_user" + } + """ + try: + player = request.player + + # Check if file is present + if 'image_file' not in request.files: + return jsonify({'error': 'No image file provided'}), 400 + + file = request.files['image_file'] + if file.filename == '': + return jsonify({'error': 'No file selected'}), 400 + + # Get metadata + import json + metadata_str = request.form.get('metadata') + if not metadata_str: + return jsonify({'error': 'No metadata provided'}), 400 + + try: + metadata = json.loads(metadata_str) + except json.JSONDecodeError: + return jsonify({'error': 'Invalid metadata JSON'}), 400 + + # Validate required metadata fields + required_fields = ['time_of_modification', 'original_name', 'new_name', 'version'] + for field in required_fields: + if field not in metadata: + return jsonify({'error': f'Missing required field: {field}'}), 400 + + # Import required modules + import os + from werkzeug.utils import secure_filename + from app.models.player_edit import PlayerEdit + + # Find the original content by filename + original_name = metadata['original_name'] + content = Content.query.filter_by(filename=original_name).first() + + if not content: + log_action('warning', f'Player {player.name} tried to edit non-existent content: {original_name}') + return jsonify({'error': f'Original content not found: {original_name}'}), 404 + + # Create versioned folder structure: edited_media// + base_upload_dir = os.path.join(current_app.root_path, 'static', 'uploads') + edited_media_dir = os.path.join(base_upload_dir, 'edited_media', str(content.id)) + os.makedirs(edited_media_dir, exist_ok=True) + + # Save the edited file with version suffix + version = metadata['version'] + new_filename = metadata['new_name'] + edited_file_path = os.path.join(edited_media_dir, new_filename) + file.save(edited_file_path) + + # Save metadata JSON file + metadata_filename = f"{os.path.splitext(new_filename)[0]}_metadata.json" + metadata_path = os.path.join(edited_media_dir, metadata_filename) + with open(metadata_path, 'w') as f: + json.dump(metadata, f, indent=2) + + # Copy the versioned image to the main uploads folder + import shutil + versioned_upload_path = os.path.join(base_upload_dir, new_filename) + shutil.copy2(edited_file_path, versioned_upload_path) + + # Update the content record to reference the new versioned filename + old_filename = content.filename + content.filename = new_filename + + # Create edit record + time_of_mod = None + if metadata.get('time_of_modification'): + try: + time_of_mod = datetime.fromisoformat(metadata['time_of_modification'].replace('Z', '+00:00')) + except: + time_of_mod = datetime.utcnow() + + edit_record = PlayerEdit( + player_id=player.id, + content_id=content.id, + original_name=original_name, + new_name=new_filename, + version=version, + user=metadata.get('user'), + time_of_modification=time_of_mod, + metadata_path=metadata_path, + edited_file_path=edited_file_path + ) + db.session.add(edit_record) + + # Update playlist version to force player refresh + if player.playlist_id: + from app.models.playlist import Playlist + playlist = db.session.get(Playlist, player.playlist_id) + if playlist: + playlist.version += 1 + + # Clear playlist cache + cache.delete_memoized(get_cached_playlist, player.id) + + db.session.commit() + + log_action('info', f'Player {player.name} uploaded edited media: {old_filename} -> {new_filename} (v{version})') + + return jsonify({ + 'success': True, + 'message': 'Edited media received and processed', + 'edit_id': edit_record.id, + 'version': version, + 'old_filename': old_filename, + 'new_filename': new_filename, + 'new_playlist_version': playlist.version if player.playlist_id and playlist else None + }), 200 + + except Exception as e: + db.session.rollback() + log_action('error', f'Error receiving edited media: {str(e)}') + return jsonify({'error': 'Internal server error'}), 500 + + @api_bp.errorhandler(404) def api_not_found(error): """Handle 404 errors in API.""" diff --git a/app/blueprints/players.py b/app/blueprints/players.py index 53b6fdf..e3a02cd 100644 --- a/app/blueprints/players.py +++ b/app/blueprints/players.py @@ -315,6 +315,13 @@ def manage_player(player_id: int): .limit(20)\ .all() + # Get edited media history from player + from app.models.player_edit import PlayerEdit + edited_media = PlayerEdit.query.filter_by(player_id=player_id)\ + .order_by(PlayerEdit.created_at.desc())\ + .limit(20)\ + .all() + # Get player status status_info = get_player_status_info(player_id) @@ -323,6 +330,7 @@ def manage_player(player_id: int): playlists=playlists, current_playlist=current_playlist, recent_logs=recent_logs, + edited_media=edited_media, status_info=status_info) diff --git a/app/models/__init__.py b/app/models/__init__.py index 5c78994..70c84ec 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,6 +6,7 @@ from app.models.playlist import Playlist, playlist_content from app.models.content import Content from app.models.server_log import ServerLog from app.models.player_feedback import PlayerFeedback +from app.models.player_edit import PlayerEdit __all__ = [ 'User', @@ -15,6 +16,7 @@ __all__ = [ 'Content', 'ServerLog', 'PlayerFeedback', + 'PlayerEdit', 'group_content', 'playlist_content', ] diff --git a/app/models/player_edit.py b/app/models/player_edit.py new file mode 100644 index 0000000..aa1cbee --- /dev/null +++ b/app/models/player_edit.py @@ -0,0 +1,60 @@ +"""Player edit model for tracking media edited on players.""" +from datetime import datetime +from typing import Optional + +from app.extensions import db + + +class PlayerEdit(db.Model): + """Player edit model for tracking media files edited on player devices. + + Attributes: + id: Primary key + player_id: Foreign key to player + content_id: Foreign key to content that was edited + original_name: Original filename + new_name: New filename after editing + version: Edit version number (v1, v2, etc.) + user: User who made the edit (from player) + time_of_modification: When the edit was made + metadata_path: Path to the metadata JSON file + edited_file_path: Path to the edited file + created_at: Record creation timestamp + """ + __tablename__ = 'player_edit' + + id = db.Column(db.Integer, primary_key=True) + player_id = db.Column(db.Integer, db.ForeignKey('player.id'), nullable=False, index=True) + content_id = db.Column(db.Integer, db.ForeignKey('content.id'), nullable=False, index=True) + original_name = db.Column(db.String(255), nullable=False) + new_name = db.Column(db.String(255), nullable=False) + version = db.Column(db.Integer, default=1, nullable=False) + user = db.Column(db.String(255), nullable=True) + time_of_modification = db.Column(db.DateTime, nullable=True) + metadata_path = db.Column(db.String(512), nullable=True) + edited_file_path = db.Column(db.String(512), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) + + # Relationships + player = db.relationship('Player', backref=db.backref('edits', lazy='dynamic')) + content = db.relationship('Content', backref=db.backref('edits', lazy='dynamic')) + + def __repr__(self) -> str: + """String representation of PlayerEdit.""" + return f'' + + def to_dict(self) -> dict: + """Convert to dictionary for API responses.""" + return { + 'id': self.id, + 'player_id': self.player_id, + 'player_name': self.player.name if self.player else None, + 'content_id': self.content_id, + 'original_name': self.original_name, + 'new_name': self.new_name, + 'version': self.version, + 'user': self.user, + 'time_of_modification': self.time_of_modification.isoformat() if self.time_of_modification else None, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'edited_file_path': self.edited_file_path + } diff --git a/app/templates/players/manage_player.html b/app/templates/players/manage_player.html index 7e5e379..ca3510d 100644 --- a/app/templates/players/manage_player.html +++ b/app/templates/players/manage_player.html @@ -247,6 +247,33 @@ color: #a0aec0 !important; } + /* Edited Media Cards Dark Mode */ + body.dark-mode .card[style*="border: 2px solid #7c3aed"] { + background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%) !important; + border-color: #8b5cf6 !important; + } + + body.dark-mode .card[style*="border: 2px solid #7c3aed"] h3 { + color: #a78bfa !important; + } + + body.dark-mode .card[style*="border: 2px solid #7c3aed"] div[style*="background: white"] { + background: #374151 !important; + } + + body.dark-mode .card[style*="border: 2px solid #7c3aed"] strong { + color: #a78bfa !important; + } + + body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #475569"], + body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #64748b"] { + color: #cbd5e1 !important; + } + + body.dark-mode div[style*="background: #f8f9fa; border-radius: 8px"] { + background: #2d3748 !important; + } + body.dark-mode .card > div[style*="text-align: center"] p { color: #a0aec0 !important; } @@ -611,6 +638,97 @@ document.addEventListener('keydown', function(event) { + +
+

+ + Edited Media on the Player +

+

History of media files edited on this player, grouped by original file

+ + {% if edited_media %} + {% set edited_by_content = {} %} + {% for edit in edited_media %} + {% if edit.content_id not in edited_by_content %} + {% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'versions': []}}) %} + {% endif %} + {% set _ = edited_by_content[edit.content_id]['versions'].append(edit) %} + {% endfor %} + +
+ {% for content_id, data in edited_by_content.items() %} +
+
+

+ âœī¸ {{ data.original_name }} +

+

+ {{ data.versions|length }} version{{ 's' if data.versions|length > 1 else '' }} +

+ +
+ {% for edit in data.versions|sort(attribute='version', reverse=True) %} +
+
+ + Version {{ edit.version }} + {% if loop.first %} + Latest + {% endif %} + + + {{ edit.created_at | localtime('%m/%d %H:%M') }} + +
+ +

+ 📄 {{ edit.new_name }} +

+ + {% if edit.user %} +

+ 👤 {{ edit.user }} +

+ {% endif %} + + {% if edit.time_of_modification %} +

+ 🕒 {{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') }} +

+ {% endif %} + + +
+ {% endfor %} +
+
+
+ {% endfor %} +
+ {% else %} +
+

📝

+

No edited media yet

+

Media edits will appear here once the player sends edited files

+
+ {% endif %} +
+

â„šī¸ Player Information