Add player media editing feature with versioning
- Added PlayerEdit model to track edited media history - Created /api/player-edit-media endpoint for receiving edited files from players - Implemented versioned storage: edited_media/<content_id>/<filename_vN.ext> - Automatic playlist update when edited media is received - Updated content.filename to reference versioned file in playlist - Added 'Edited Media on the Player' card to player management page - UI shows version history grouped by original file - Each edit preserves previous versions in archive folder - Includes dark mode support for new UI elements - Documentation: PLAYER_EDIT_MEDIA_API.md
This commit is contained in:
181
PLAYER_EDIT_MEDIA_API.md
Normal file
181
PLAYER_EDIT_MEDIA_API.md
Normal file
@@ -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/<content_id>/<new_name>`
|
||||
- Saves metadata JSON to `/static/uploads/edited_media/<content_id>/<new_name>_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/<content_id>/` 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
|
||||
@@ -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/<int:player_id>', 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/<content_id>/
|
||||
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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
60
app/models/player_edit.py
Normal file
60
app/models/player_edit.py
Normal file
@@ -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'<PlayerEdit {self.original_name} v{self.version} by {self.user or "unknown"}>'
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Edited Media Section - Full Width -->
|
||||
<div class="card" style="margin-top: 2rem;">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Edited Media on the Player
|
||||
</h2>
|
||||
<p style="color: #6c757d; font-size: 0.9rem;">History of media files edited on this player, grouped by original file</p>
|
||||
|
||||
{% 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 %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;">
|
||||
{% for content_id, data in edited_by_content.items() %}
|
||||
<div class="card" style="background: linear-gradient(135deg, #f8f9fa 0%, #fff 100%); border: 2px solid #7c3aed; box-shadow: 0 2px 8px rgba(124, 58, 237, 0.1);">
|
||||
<div style="padding: 1rem;">
|
||||
<h3 style="margin: 0 0 0.75rem 0; color: #7c3aed; font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
✏️ {{ data.original_name }}
|
||||
</h3>
|
||||
<p style="margin: 0 0 1rem 0; font-size: 0.85rem; color: #6c757d;">
|
||||
{{ data.versions|length }} version{{ 's' if data.versions|length > 1 else '' }}
|
||||
</p>
|
||||
|
||||
<div style="max-height: 400px; overflow-y: auto;">
|
||||
{% for edit in data.versions|sort(attribute='version', reverse=True) %}
|
||||
<div style="padding: 0.75rem; margin-bottom: 0.75rem; background: white; border-radius: 6px; border-left: 4px solid {% if loop.first %}#7c3aed{% else %}#cbd5e1{% endif %}; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
|
||||
<strong style="color: {% if loop.first %}#7c3aed{% else %}#64748b{% endif %}; font-size: 0.95rem;">
|
||||
Version {{ edit.version }}
|
||||
{% if loop.first %}
|
||||
<span style="background: #7c3aed; color: white; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; margin-left: 0.5rem;">Latest</span>
|
||||
{% endif %}
|
||||
</strong>
|
||||
<small style="color: #6c757d; white-space: nowrap;">
|
||||
{{ edit.created_at | localtime('%m/%d %H:%M') }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<p style="margin: 0.25rem 0; font-size: 0.85rem; color: #475569;">
|
||||
📄 {{ edit.new_name }}
|
||||
</p>
|
||||
|
||||
{% if edit.user %}
|
||||
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
|
||||
👤 {{ edit.user }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if edit.time_of_modification %}
|
||||
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
|
||||
🕒 {{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
|
||||
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||
target="_blank"
|
||||
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #7c3aed; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#6d28d9'"
|
||||
onmouseout="this.style.background='#7c3aed'">
|
||||
📥 View File
|
||||
</a>
|
||||
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||
download
|
||||
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #64748b; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#475569'"
|
||||
onmouseout="this.style.background='#64748b'">
|
||||
💾 Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 3rem; color: #6c757d; background: #f8f9fa; border-radius: 8px; margin-top: 1.5rem;">
|
||||
<p style="font-size: 2rem; margin: 0;">📝</p>
|
||||
<p style="margin: 0.5rem 0 0 0; font-size: 1.1rem; font-weight: 500;">No edited media yet</p>
|
||||
<p style="font-size: 0.9rem; margin: 0.5rem 0 0 0;">Media edits will appear here once the player sends edited files</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Additional Info Section -->
|
||||
<div class="card" style="margin-top: 2rem;">
|
||||
<h2>ℹ️ Player Information</h2>
|
||||
|
||||
Reference in New Issue
Block a user