updated features to upload pptx files
This commit is contained in:
@@ -1,336 +0,0 @@
|
|||||||
# Blueprint Architecture - Quick Reference
|
|
||||||
|
|
||||||
## 📦 Blueprint Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
app/
|
|
||||||
├── blueprints/
|
|
||||||
│ ├── __init__.py # Package initialization
|
|
||||||
│ ├── main.py # Dashboard, health check
|
|
||||||
│ ├── auth.py # Login, register, logout
|
|
||||||
│ ├── admin.py # Admin panel, user management
|
|
||||||
│ ├── players.py # Player CRUD, fullscreen view
|
|
||||||
│ ├── groups.py # Group management, assignments
|
|
||||||
│ ├── content.py # Media upload, file management
|
|
||||||
│ └── api.py # REST API endpoints
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 URL Mapping
|
|
||||||
|
|
||||||
### Main Blueprint (`/`)
|
|
||||||
- `GET /` - Dashboard with statistics
|
|
||||||
- `GET /health` - Health check (database, disk)
|
|
||||||
|
|
||||||
### Auth Blueprint (`/auth`)
|
|
||||||
- `GET /auth/login` - Login page
|
|
||||||
- `POST /auth/login` - Process login
|
|
||||||
- `GET /auth/logout` - Logout
|
|
||||||
- `GET /auth/register` - Register page
|
|
||||||
- `POST /auth/register` - Process registration
|
|
||||||
- `GET /auth/change-password` - Change password page
|
|
||||||
- `POST /auth/change-password` - Update password
|
|
||||||
|
|
||||||
### Admin Blueprint (`/admin`)
|
|
||||||
- `GET /admin/` - Admin panel
|
|
||||||
- `POST /admin/user/create` - Create user
|
|
||||||
- `POST /admin/user/<id>/role` - Change user role
|
|
||||||
- `POST /admin/user/<id>/delete` - Delete user
|
|
||||||
- `POST /admin/theme` - Change theme
|
|
||||||
- `POST /admin/logo/upload` - Upload logo
|
|
||||||
- `POST /admin/logs/clear` - Clear logs
|
|
||||||
- `GET /admin/system/info` - System info (JSON)
|
|
||||||
|
|
||||||
### Players Blueprint (`/players`)
|
|
||||||
- `GET /players/` - List all players
|
|
||||||
- `GET /players/add` - Add player page
|
|
||||||
- `POST /players/add` - Create player
|
|
||||||
- `GET /players/<id>/edit` - Edit player page
|
|
||||||
- `POST /players/<id>/edit` - Update player
|
|
||||||
- `POST /players/<id>/delete` - Delete player
|
|
||||||
- `POST /players/<id>/regenerate-auth` - Regenerate auth code
|
|
||||||
- `GET /players/<id>` - Player page
|
|
||||||
- `GET /players/<id>/fullscreen` - Player fullscreen view
|
|
||||||
- `POST /players/<id>/reorder` - Reorder content
|
|
||||||
- `POST /players/bulk/delete` - Bulk delete players
|
|
||||||
- `POST /players/bulk/assign-group` - Bulk assign to group
|
|
||||||
|
|
||||||
### Groups Blueprint (`/groups`)
|
|
||||||
- `GET /groups/` - List all groups
|
|
||||||
- `GET /groups/create` - Create group page
|
|
||||||
- `POST /groups/create` - Create group
|
|
||||||
- `GET /groups/<id>/edit` - Edit group page
|
|
||||||
- `POST /groups/<id>/edit` - Update group
|
|
||||||
- `POST /groups/<id>/delete` - Delete group
|
|
||||||
- `GET /groups/<id>/manage` - Manage group page
|
|
||||||
- `GET /groups/<id>/fullscreen` - Group fullscreen view
|
|
||||||
- `POST /groups/<id>/add-player` - Add player to group
|
|
||||||
- `POST /groups/<id>/remove-player/<player_id>` - Remove player
|
|
||||||
- `POST /groups/<id>/add-content` - Add content to group
|
|
||||||
- `POST /groups/<id>/remove-content/<content_id>` - Remove content
|
|
||||||
- `POST /groups/<id>/reorder-content` - Reorder content
|
|
||||||
- `GET /groups/<id>/stats` - Group statistics (JSON)
|
|
||||||
|
|
||||||
### Content Blueprint (`/content`)
|
|
||||||
- `GET /content/` - List all content
|
|
||||||
- `GET /content/upload` - Upload page
|
|
||||||
- `POST /content/upload` - Upload file
|
|
||||||
- `GET /content/<id>/edit` - Edit content page
|
|
||||||
- `POST /content/<id>/edit` - Update content
|
|
||||||
- `POST /content/<id>/delete` - Delete content
|
|
||||||
- `POST /content/bulk/delete` - Bulk delete content
|
|
||||||
- `GET /content/upload-progress/<upload_id>` - Upload progress (JSON)
|
|
||||||
- `GET /content/preview/<id>` - Preview content
|
|
||||||
- `GET /content/<id>/download` - Download content
|
|
||||||
- `GET /content/statistics` - Content statistics (JSON)
|
|
||||||
- `GET /content/check-duplicates` - Check duplicates (JSON)
|
|
||||||
- `GET /content/<id>/groups` - Content groups info (JSON)
|
|
||||||
|
|
||||||
### API Blueprint (`/api`)
|
|
||||||
- `GET /api/health` - API health check
|
|
||||||
- `GET /api/playlists/<player_id>` - Get player playlist (auth required)
|
|
||||||
- `POST /api/player-feedback` - Submit player feedback (auth required)
|
|
||||||
- `GET /api/player-status/<player_id>` - Get player status
|
|
||||||
- `GET /api/upload-progress/<upload_id>` - Get upload progress
|
|
||||||
- `GET /api/system-info` - System statistics
|
|
||||||
- `GET /api/groups` - List all groups
|
|
||||||
- `GET /api/content` - List all content
|
|
||||||
- `GET /api/logs` - Get server logs (query params: limit, level, since)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔒 Authentication & Authorization
|
|
||||||
|
|
||||||
### Login Required
|
|
||||||
Most routes require authentication via `@login_required` decorator:
|
|
||||||
```python
|
|
||||||
from flask_login import login_required
|
|
||||||
|
|
||||||
@players_bp.route('/')
|
|
||||||
@login_required
|
|
||||||
def players_list():
|
|
||||||
# Route logic
|
|
||||||
```
|
|
||||||
|
|
||||||
### Admin Required
|
|
||||||
Admin routes use custom `@admin_required` decorator:
|
|
||||||
```python
|
|
||||||
from app.blueprints.admin import admin_required
|
|
||||||
|
|
||||||
@admin_bp.route('/')
|
|
||||||
@login_required
|
|
||||||
@admin_required
|
|
||||||
def admin_panel():
|
|
||||||
# Route logic
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Authentication
|
|
||||||
API routes use `@verify_player_auth` for player authentication:
|
|
||||||
```python
|
|
||||||
from app.blueprints.api import verify_player_auth
|
|
||||||
|
|
||||||
@api_bp.route('/playlists/<int:player_id>')
|
|
||||||
@verify_player_auth
|
|
||||||
def get_player_playlist(player_id):
|
|
||||||
# Access authenticated player via request.player
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Performance Features
|
|
||||||
|
|
||||||
### Caching
|
|
||||||
Routes with caching enabled:
|
|
||||||
- Dashboard: 60 seconds
|
|
||||||
- Player playlist: 5 minutes (memoized function)
|
|
||||||
|
|
||||||
Clear cache on data changes:
|
|
||||||
```python
|
|
||||||
from app.extensions import cache
|
|
||||||
|
|
||||||
# Clear specific memoized function
|
|
||||||
cache.delete_memoized(get_player_playlist, player_id)
|
|
||||||
|
|
||||||
# Clear all cache
|
|
||||||
cache.clear()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rate Limiting
|
|
||||||
API endpoints have rate limiting:
|
|
||||||
```python
|
|
||||||
@api_bp.route('/playlists/<int:player_id>')
|
|
||||||
@rate_limit(max_requests=30, window=60) # 30 requests per minute
|
|
||||||
def get_player_playlist(player_id):
|
|
||||||
# Route logic
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Template Usage
|
|
||||||
|
|
||||||
### URL Generation
|
|
||||||
Always use blueprint-qualified names in templates:
|
|
||||||
|
|
||||||
**Old (v1):**
|
|
||||||
```html
|
|
||||||
<a href="{{ url_for('login') }}">Login</a>
|
|
||||||
<a href="{{ url_for('dashboard') }}">Dashboard</a>
|
|
||||||
<a href="{{ url_for('add_player') }}">Add Player</a>
|
|
||||||
```
|
|
||||||
|
|
||||||
**New (v2):**
|
|
||||||
```html
|
|
||||||
<a href="{{ url_for('auth.login') }}">Login</a>
|
|
||||||
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
|
|
||||||
<a href="{{ url_for('players.add_player') }}">Add Player</a>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Context Processors
|
|
||||||
Available in all templates:
|
|
||||||
- `server_version` - Server version string
|
|
||||||
- `build_date` - Build date string
|
|
||||||
- `logo_exists` - Boolean, whether custom logo exists
|
|
||||||
- `theme` - Current theme ('light' or 'dark')
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Common Patterns
|
|
||||||
|
|
||||||
### Creating a New Route
|
|
||||||
|
|
||||||
1. **Choose the appropriate blueprint** based on functionality
|
|
||||||
2. **Add the route** with proper decorators:
|
|
||||||
```python
|
|
||||||
@blueprint_name.route('/path', methods=['GET', 'POST'])
|
|
||||||
@login_required # If authentication needed
|
|
||||||
def route_name():
|
|
||||||
try:
|
|
||||||
# Route logic
|
|
||||||
log_action('info', 'Success message')
|
|
||||||
flash('Success message', 'success')
|
|
||||||
return redirect(url_for('blueprint.route'))
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error: {str(e)}')
|
|
||||||
flash('Error message', 'danger')
|
|
||||||
return redirect(url_for('blueprint.route'))
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding Caching
|
|
||||||
|
|
||||||
For view caching:
|
|
||||||
```python
|
|
||||||
@cache.cached(timeout=60, key_prefix='my_key')
|
|
||||||
def my_route():
|
|
||||||
# Route logic
|
|
||||||
```
|
|
||||||
|
|
||||||
For function memoization:
|
|
||||||
```python
|
|
||||||
@cache.memoize(timeout=300)
|
|
||||||
def my_function(param):
|
|
||||||
# Function logic
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Response Format
|
|
||||||
|
|
||||||
Consistent JSON responses:
|
|
||||||
```python
|
|
||||||
# Success
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'data': {...}
|
|
||||||
})
|
|
||||||
|
|
||||||
# Error
|
|
||||||
return jsonify({
|
|
||||||
'error': 'Error message'
|
|
||||||
}), 400
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Extension Access
|
|
||||||
|
|
||||||
Import extensions from centralized location:
|
|
||||||
```python
|
|
||||||
from app.extensions import db, bcrypt, cache, login_manager
|
|
||||||
```
|
|
||||||
|
|
||||||
Never initialize extensions directly in blueprints - they're initialized in `extensions.py` and registered in `app.py`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testing Routes
|
|
||||||
|
|
||||||
### Manual Testing
|
|
||||||
```bash
|
|
||||||
# Start development server
|
|
||||||
flask run
|
|
||||||
|
|
||||||
# Test specific blueprint
|
|
||||||
curl http://localhost:5000/api/health
|
|
||||||
curl http://localhost:5000/health
|
|
||||||
|
|
||||||
# Test authenticated route
|
|
||||||
curl -H "Authorization: Bearer <auth_code>" http://localhost:5000/api/playlists/1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Unit Testing
|
|
||||||
```python
|
|
||||||
def test_dashboard(client, auth):
|
|
||||||
auth.login()
|
|
||||||
response = client.get('/')
|
|
||||||
assert response.status_code == 200
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Blueprint Registration
|
|
||||||
|
|
||||||
Blueprints are registered in `app/app.py`:
|
|
||||||
```python
|
|
||||||
def register_blueprints(app: Flask) -> None:
|
|
||||||
"""Register all blueprints."""
|
|
||||||
from app.blueprints.main import main_bp
|
|
||||||
from app.blueprints.auth import auth_bp
|
|
||||||
from app.blueprints.admin import admin_bp
|
|
||||||
from app.blueprints.players import players_bp
|
|
||||||
from app.blueprints.groups import groups_bp
|
|
||||||
from app.blueprints.content import content_bp
|
|
||||||
from app.blueprints.api import api_bp
|
|
||||||
|
|
||||||
app.register_blueprint(main_bp)
|
|
||||||
app.register_blueprint(auth_bp)
|
|
||||||
app.register_blueprint(admin_bp)
|
|
||||||
app.register_blueprint(players_bp)
|
|
||||||
app.register_blueprint(groups_bp)
|
|
||||||
app.register_blueprint(content_bp)
|
|
||||||
app.register_blueprint(api_bp)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Checklist for Adding New Features
|
|
||||||
|
|
||||||
- [ ] Choose appropriate blueprint
|
|
||||||
- [ ] Add route with proper decorators
|
|
||||||
- [ ] Implement error handling (try/except)
|
|
||||||
- [ ] Add logging with `log_action()`
|
|
||||||
- [ ] Add flash messages for user feedback
|
|
||||||
- [ ] Clear cache if data changes
|
|
||||||
- [ ] Add type hints to parameters
|
|
||||||
- [ ] Update this documentation
|
|
||||||
- [ ] Add unit tests
|
|
||||||
- [ ] Test manually in browser/API client
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This architecture provides:
|
|
||||||
- ✅ **Separation of concerns** - Each blueprint handles specific functionality
|
|
||||||
- ✅ **Scalability** - Easy to add new blueprints
|
|
||||||
- ✅ **Maintainability** - Clear organization and naming
|
|
||||||
- ✅ **Performance** - Built-in caching and optimization
|
|
||||||
- ✅ **Security** - Proper authentication and authorization
|
|
||||||
- ✅ **API-first** - RESTful API alongside web interface
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# SVG Icon Integration
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Replaced all emoji icons with local SVG files to ensure consistent rendering across all systems, particularly on Raspberry Pi devices where emoji fonts may not be available.
|
|
||||||
|
|
||||||
## Icon Files Created
|
|
||||||
Location: `/app/static/icons/`
|
|
||||||
|
|
||||||
1. **moon.svg** - Dark mode toggle (off state)
|
|
||||||
2. **sun.svg** - Dark mode toggle (on state)
|
|
||||||
3. **home.svg** - Dashboard/home navigation
|
|
||||||
4. **monitor.svg** - Players/screens
|
|
||||||
5. **playlist.svg** - Playlist management
|
|
||||||
6. **edit.svg** - Edit actions
|
|
||||||
7. **trash.svg** - Delete actions
|
|
||||||
8. **upload.svg** - Upload/media files
|
|
||||||
9. **info.svg** - Information/details
|
|
||||||
10. **warning.svg** - Warnings/alerts
|
|
||||||
|
|
||||||
## Icon Specifications
|
|
||||||
- **Size**: 24x24 viewBox (scalable)
|
|
||||||
- **Style**: Feather Icons design (minimal, stroke-based)
|
|
||||||
- **Color**: Uses `currentColor` for automatic theme adaptation
|
|
||||||
- **Stroke**: 2px width, round line caps and joins
|
|
||||||
- **Format**: Clean SVG with no fills (stroke-only for consistency)
|
|
||||||
|
|
||||||
## Templates Updated
|
|
||||||
|
|
||||||
### 1. base.html (Navigation & Dark Mode)
|
|
||||||
- Header logo: monitor.svg
|
|
||||||
- Dashboard link: home.svg
|
|
||||||
- Players link: monitor.svg
|
|
||||||
- Playlists link: playlist.svg
|
|
||||||
- Dark mode toggle: moon.svg ↔ sun.svg (dynamic)
|
|
||||||
|
|
||||||
**CSS Changes:**
|
|
||||||
- Added icon support to nav links with `display: flex`
|
|
||||||
- Icons use `filter: brightness(0) invert(1)` for white color on dark header
|
|
||||||
- Dark mode toggle icon changes via JavaScript using `src` attribute
|
|
||||||
|
|
||||||
### 2. dashboard.html (Stats Cards & Quick Actions)
|
|
||||||
- Players card: monitor.svg
|
|
||||||
- Playlists card: playlist.svg
|
|
||||||
- Media Library card: upload.svg
|
|
||||||
- Storage card: info.svg
|
|
||||||
- Quick Actions buttons: monitor.svg, playlist.svg, upload.svg
|
|
||||||
- Workflow Guide header: info.svg
|
|
||||||
|
|
||||||
### 3. content_list_new.html (Playlist Management)
|
|
||||||
- Page header: playlist.svg
|
|
||||||
- Playlists card header: playlist.svg
|
|
||||||
- Delete button: trash.svg
|
|
||||||
- Empty state: playlist.svg (64px, opacity 0.3)
|
|
||||||
- Upload Media card header: upload.svg
|
|
||||||
- Upload icon (large): upload.svg (96px, opacity 0.5)
|
|
||||||
- Go to Upload button: upload.svg
|
|
||||||
- Media library icons: info.svg (images), monitor.svg (videos)
|
|
||||||
- Player Assignments header: monitor.svg
|
|
||||||
|
|
||||||
### 4. upload_media.html (Upload Page)
|
|
||||||
- Page header: upload.svg
|
|
||||||
- Back button: playlist.svg
|
|
||||||
- Select Files card: upload.svg
|
|
||||||
- Drag-drop zone: upload.svg (96px, opacity 0.3)
|
|
||||||
- Browse button: upload.svg
|
|
||||||
- Upload Settings header: info.svg
|
|
||||||
- Upload button: upload.svg
|
|
||||||
|
|
||||||
### 5. manage_player.html (Player Management)
|
|
||||||
- Page header: monitor.svg
|
|
||||||
- Back button: monitor.svg
|
|
||||||
- Status icons: info.svg (online), warning.svg (offline)
|
|
||||||
- Edit Credentials card: edit.svg
|
|
||||||
- Save button: edit.svg
|
|
||||||
- Assign Playlist card: playlist.svg
|
|
||||||
- Warning alert: warning.svg
|
|
||||||
- Assign button: playlist.svg
|
|
||||||
- Create Playlist button: playlist.svg
|
|
||||||
- Edit Playlist button: edit.svg
|
|
||||||
- Player Logs card: info.svg
|
|
||||||
|
|
||||||
## Usage Pattern
|
|
||||||
```html
|
|
||||||
<!-- Standard icon in heading -->
|
|
||||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
|
||||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
|
|
||||||
Playlists
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- Icon in button (white on colored background) -->
|
|
||||||
<button class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
|
|
||||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
|
||||||
Upload Files
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Large decorative icon -->
|
|
||||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 96px; height: 96px; opacity: 0.3;">
|
|
||||||
```
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
1. **Reliability**: Icons always render correctly, no dependency on system fonts
|
|
||||||
2. **Scalability**: SVG format scales cleanly to any size
|
|
||||||
3. **Themeable**: `currentColor` allows icons to inherit text color
|
|
||||||
4. **Performance**: Small file sizes (< 1KB each)
|
|
||||||
5. **Consistency**: Uniform design language across entire application
|
|
||||||
6. **Accessibility**: Clean, recognizable icons work well in both light and dark modes
|
|
||||||
|
|
||||||
## Browser Compatibility
|
|
||||||
- All modern browsers support inline SVG
|
|
||||||
- No JavaScript required (except dark mode toggle)
|
|
||||||
- Works on Raspberry Pi browsers (Chromium, Firefox)
|
|
||||||
- Filter properties supported in all target browsers
|
|
||||||
|
|
||||||
## Dark Mode Integration
|
|
||||||
The dark mode toggle now uses SVG icons instead of emoji:
|
|
||||||
- Moon icon (🌙) → moon.svg
|
|
||||||
- Sun icon (☀️) → sun.svg
|
|
||||||
- Icons change dynamically via JavaScript when theme is toggled
|
|
||||||
- White color achieved via CSS filter on dark header background
|
|
||||||
|
|
||||||
## Future Icon Needs
|
|
||||||
If additional icons are needed, follow this pattern:
|
|
||||||
1. Use Feather Icons style (https://feathericons.com/)
|
|
||||||
2. 24x24 viewBox, stroke-width="2"
|
|
||||||
3. Use `stroke="currentColor"` for theme compatibility
|
|
||||||
4. Save to `/app/static/icons/`
|
|
||||||
5. Reference via `{{ url_for('static', filename='icons/name.svg') }}`
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
- [x] Header navigation icons display correctly
|
|
||||||
- [x] Dark mode toggle icons switch properly
|
|
||||||
- [x] Dashboard card icons visible
|
|
||||||
- [x] Playlist management icons render
|
|
||||||
- [x] Upload page icons functional
|
|
||||||
- [x] Player management icons working
|
|
||||||
- [ ] Test on actual Raspberry Pi device
|
|
||||||
- [ ] Verify icon visibility in both light/dark modes
|
|
||||||
- [ ] Check icon alignment across different screen sizes
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
# Kivy Player Compatibility with New Playlist Architecture
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
This document outlines the compatibility verification and updates made to ensure the Kivy signage player works with the new playlist-centric architecture.
|
|
||||||
|
|
||||||
## Changes Made to DigiServer API
|
|
||||||
|
|
||||||
### 1. Content URL Format in Playlist Response
|
|
||||||
**Location:** `/home/pi/Desktop/digiserver-v2/app/blueprints/api.py` - `get_cached_playlist()` function
|
|
||||||
|
|
||||||
**Issue:**
|
|
||||||
- Player expects: `file_name` key in playlist items
|
|
||||||
- Server was returning: `filename` key
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
```python
|
|
||||||
playlist_data.append({
|
|
||||||
'id': content.id,
|
|
||||||
'file_name': content.filename, # Changed from 'filename' to 'file_name'
|
|
||||||
'type': content.content_type,
|
|
||||||
'duration': content._playlist_duration or content.duration or 10,
|
|
||||||
'position': content._playlist_position or idx,
|
|
||||||
'url': content_url, # Now returns full URL with server base
|
|
||||||
'description': content.description
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**What Changed:**
|
|
||||||
- ✅ Changed `'filename'` to `'file_name'` to match player expectations
|
|
||||||
- ✅ URL now includes full server base URL (e.g., `http://server:5000/static/uploads/image.jpg`)
|
|
||||||
- ✅ Player can now download content directly without URL manipulation
|
|
||||||
|
|
||||||
### 2. Authentication Response - Removed group_id
|
|
||||||
**Location:** `/home/pi/Desktop/digiserver-v2/app/blueprints/api.py`
|
|
||||||
|
|
||||||
**Endpoints Updated:**
|
|
||||||
1. `/api/auth/player` (line ~145) - ✅ Already returns `playlist_id`
|
|
||||||
2. `/api/auth/verify` (line ~192) - ✅ Changed from `group_id` to `playlist_id`
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
```python
|
|
||||||
response = {
|
|
||||||
'valid': True,
|
|
||||||
'player_id': player.id,
|
|
||||||
'player_name': player.name,
|
|
||||||
'hostname': player.hostname,
|
|
||||||
'group_id': player.group_id, # OLD
|
|
||||||
'orientation': player.orientation,
|
|
||||||
'status': player.status
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```python
|
|
||||||
response = {
|
|
||||||
'valid': True,
|
|
||||||
'player_id': player.id,
|
|
||||||
'player_name': player.name,
|
|
||||||
'hostname': player.hostname,
|
|
||||||
'playlist_id': player.playlist_id, # NEW
|
|
||||||
'orientation': player.orientation,
|
|
||||||
'status': player.status
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Changes Made to Kivy Player
|
|
||||||
|
|
||||||
### 1. Updated player_auth.py
|
|
||||||
**Location:** `/home/pi/Desktop/Kiwy-Signage/src/player_auth.py`
|
|
||||||
|
|
||||||
**Changes:**
|
|
||||||
1. ✅ Default auth data structure: `group_id` → `playlist_id` (line ~42)
|
|
||||||
2. ✅ Authentication data storage: `group_id` → `playlist_id` (line ~97)
|
|
||||||
3. ✅ Clear auth method: `group_id` → `playlist_id` (line ~299)
|
|
||||||
4. ✅ Example usage output: Updated to show `playlist_id` instead of `group_id`
|
|
||||||
|
|
||||||
**Impact:**
|
|
||||||
- Player now stores and uses `playlist_id` instead of deprecated `group_id`
|
|
||||||
- Backward compatible: old auth files will load but won't have `playlist_id` until re-authentication
|
|
||||||
|
|
||||||
## API Endpoints Used by Kivy Player
|
|
||||||
|
|
||||||
### Player Authentication Flow
|
|
||||||
```
|
|
||||||
1. POST /api/auth/player
|
|
||||||
Body: { hostname, password OR quickconnect_code }
|
|
||||||
Returns: { auth_code, player_id, playlist_id, orientation, ... }
|
|
||||||
|
|
||||||
2. GET /api/playlists/<player_id>
|
|
||||||
Headers: Authorization: Bearer <auth_code>
|
|
||||||
Returns: { playlist: [...], playlist_version: N, ... }
|
|
||||||
|
|
||||||
3. GET <full_url_from_playlist_item>
|
|
||||||
Downloads content file
|
|
||||||
```
|
|
||||||
|
|
||||||
### Expected Playlist Format
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"player_id": 1,
|
|
||||||
"player_name": "Player-001",
|
|
||||||
"playlist_id": 5,
|
|
||||||
"playlist_version": 3,
|
|
||||||
"playlist": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"file_name": "demo1.jpg",
|
|
||||||
"type": "image",
|
|
||||||
"duration": 10,
|
|
||||||
"position": 1,
|
|
||||||
"url": "http://192.168.1.100:5000/static/uploads/demo1.jpg",
|
|
||||||
"description": "Demo Image 1"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification Checklist
|
|
||||||
|
|
||||||
### Server-Side ✅
|
|
||||||
- [x] Player model has `playlist_id` field
|
|
||||||
- [x] Content model has unique filenames
|
|
||||||
- [x] Playlist model with version tracking
|
|
||||||
- [x] API returns `file_name` (not `filename`)
|
|
||||||
- [x] API returns full URLs for content
|
|
||||||
- [x] API returns `playlist_id` in auth responses
|
|
||||||
- [x] `/api/playlists/<player_id>` endpoint exists and works
|
|
||||||
- [x] Bearer token authentication works
|
|
||||||
|
|
||||||
### Client-Side ✅
|
|
||||||
- [x] PlayerAuth class uses `playlist_id`
|
|
||||||
- [x] get_playlists_v2.py can fetch playlists
|
|
||||||
- [x] Player can download content from full URLs
|
|
||||||
- [x] Player parses `file_name` correctly
|
|
||||||
- [x] Player uses Bearer token auth
|
|
||||||
|
|
||||||
## Workflow Example
|
|
||||||
|
|
||||||
### 1. Server Setup
|
|
||||||
```bash
|
|
||||||
# Create a playlist
|
|
||||||
curl -X POST http://server:5000/content/create_playlist \
|
|
||||||
-d "name=Morning Playlist"
|
|
||||||
|
|
||||||
# Upload content
|
|
||||||
curl -X POST http://server:5000/content/upload_media \
|
|
||||||
-F "file=@demo1.jpg"
|
|
||||||
|
|
||||||
# Add content to playlist
|
|
||||||
curl -X POST http://server:5000/content/add_content_to_playlist \
|
|
||||||
-d "playlist_id=1&content_id=1&duration=10"
|
|
||||||
|
|
||||||
# Create player
|
|
||||||
curl -X POST http://server:5000/players/add \
|
|
||||||
-d "name=Player-001&hostname=player001&quickconnect_code=QUICK123"
|
|
||||||
|
|
||||||
# Assign playlist to player
|
|
||||||
curl -X POST http://server:5000/content/assign_player_to_playlist \
|
|
||||||
-d "player_id=1&playlist_id=1"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Player Authentication
|
|
||||||
```python
|
|
||||||
from player_auth import PlayerAuth
|
|
||||||
|
|
||||||
auth = PlayerAuth()
|
|
||||||
success, error = auth.authenticate(
|
|
||||||
server_url='http://192.168.1.100:5000',
|
|
||||||
hostname='player001',
|
|
||||||
quickconnect_code='QUICK123'
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print(f"✅ Authenticated as {auth.get_player_name()}")
|
|
||||||
print(f"📋 Playlist ID: {auth.auth_data['playlist_id']}")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Fetch and Download Playlist
|
|
||||||
```python
|
|
||||||
from get_playlists_v2 import update_playlist_if_needed
|
|
||||||
|
|
||||||
config = {
|
|
||||||
'server_ip': '192.168.1.100',
|
|
||||||
'port': '5000',
|
|
||||||
'screen_name': 'player001',
|
|
||||||
'quickconnect_key': 'QUICK123'
|
|
||||||
}
|
|
||||||
|
|
||||||
playlist_file = update_playlist_if_needed(
|
|
||||||
config=config,
|
|
||||||
playlist_dir='playlists/',
|
|
||||||
media_dir='media/'
|
|
||||||
)
|
|
||||||
|
|
||||||
if playlist_file:
|
|
||||||
print(f"✅ Playlist downloaded: {playlist_file}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Recommendations
|
|
||||||
|
|
||||||
1. **Test Authentication:**
|
|
||||||
- Test with valid quickconnect code
|
|
||||||
- Test with invalid quickconnect code
|
|
||||||
- Verify `playlist_id` is returned
|
|
||||||
|
|
||||||
2. **Test Playlist Fetching:**
|
|
||||||
- Fetch playlist with auth code
|
|
||||||
- Verify `file_name` and full URLs in response
|
|
||||||
- Test playlist version tracking
|
|
||||||
|
|
||||||
3. **Test Content Download:**
|
|
||||||
- Download images from full URLs
|
|
||||||
- Download videos from full URLs
|
|
||||||
- Verify files save correctly
|
|
||||||
|
|
||||||
4. **Test Playlist Updates:**
|
|
||||||
- Update playlist version on server
|
|
||||||
- Player should detect new version
|
|
||||||
- Player should download only new content
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
### For Existing Deployments
|
|
||||||
1. **Server:** Database recreated with new schema (groups removed)
|
|
||||||
2. **Players:** Will re-authenticate on next check
|
|
||||||
3. **Auth Files:** Old `player_auth.json` files are compatible but will update on next auth
|
|
||||||
|
|
||||||
### Backward Compatibility
|
|
||||||
- ⚠️ Legacy `/api/playlists?hostname=X&quickconnect_code=Y` endpoint still exists
|
|
||||||
- ⚠️ New v2 auth flow preferred (uses Bearer tokens)
|
|
||||||
- ⚠️ Old player code using groups will need updates
|
|
||||||
|
|
||||||
## Known Issues / Future Improvements
|
|
||||||
|
|
||||||
1. **Content Cleanup:** Old media files not referenced by playlists should be deleted
|
|
||||||
2. **Playlist Caching:** Cache invalidation when playlist updated on server
|
|
||||||
3. **Partial Updates:** Download only changed content instead of full playlist
|
|
||||||
4. **Network Resilience:** Better handling of connection failures
|
|
||||||
5. **Progress Feedback:** Real-time download progress for large files
|
|
||||||
|
|
||||||
## Contact
|
|
||||||
For issues or questions about player compatibility, check:
|
|
||||||
- Server logs: `/home/pi/Desktop/digiserver-v2/instance/logs/`
|
|
||||||
- Player logs: See PlayerAuth logger output
|
|
||||||
- API documentation: Check `/api/health` endpoint
|
|
||||||
|
|
||||||
---
|
|
||||||
**Last Updated:** 2025-01-XX
|
|
||||||
**Version:** DigiServer v2.0 with Playlist Architecture
|
|
||||||
381
PLAYER_AUTH.md
381
PLAYER_AUTH.md
@@ -1,381 +0,0 @@
|
|||||||
# Player Authentication System - DigiServer v2 & Kiwy-Signage
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
DigiServer v2 now includes a secure player authentication system compatible with Kiwy-Signage players. Players can authenticate using either a password or quick connect code, and their credentials are securely stored locally.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
✅ **Dual Authentication Methods**
|
|
||||||
- Password-based authentication (secure bcrypt hashing)
|
|
||||||
- Quick Connect codes for easy pairing
|
|
||||||
|
|
||||||
✅ **Secure Credential Storage**
|
|
||||||
- Auth codes saved locally in encrypted configuration
|
|
||||||
- No need to re-authenticate on every restart
|
|
||||||
|
|
||||||
✅ **Automatic Session Management**
|
|
||||||
- Auth codes persist across player restarts
|
|
||||||
- Automatic status updates and heartbeats
|
|
||||||
|
|
||||||
✅ **Player Identification**
|
|
||||||
- Unique hostname for each player
|
|
||||||
- Configurable display orientation (Landscape/Portrait)
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
The Player model now includes:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Player(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
name = db.Column(db.String(255), nullable=False)
|
|
||||||
hostname = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
|
||||||
location = db.Column(db.String(255), nullable=True)
|
|
||||||
auth_code = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
|
||||||
password_hash = db.Column(db.String(255), nullable=False)
|
|
||||||
quickconnect_code = db.Column(db.String(255), nullable=True)
|
|
||||||
group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True)
|
|
||||||
orientation = db.Column(db.String(16), default='Landscape')
|
|
||||||
status = db.Column(db.String(50), default='offline')
|
|
||||||
last_seen = db.Column(db.DateTime, nullable=True)
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### 1. Player Authentication
|
|
||||||
**POST** `/api/auth/player`
|
|
||||||
|
|
||||||
Authenticate a player and receive auth code.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"hostname": "player-001",
|
|
||||||
"password": "your_password",
|
|
||||||
"quickconnect_code": "QUICK123" // Optional if using password
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"player_id": 1,
|
|
||||||
"player_name": "Demo Player",
|
|
||||||
"hostname": "player-001",
|
|
||||||
"auth_code": "abc123xyz...",
|
|
||||||
"group_id": 5,
|
|
||||||
"orientation": "Landscape",
|
|
||||||
"status": "online"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error Response (401 Unauthorized):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Invalid credentials"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Verify Auth Code
|
|
||||||
**POST** `/api/auth/verify`
|
|
||||||
|
|
||||||
Verify an existing auth code.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"auth_code": "abc123xyz..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response (200 OK):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"valid": true,
|
|
||||||
"player_id": 1,
|
|
||||||
"player_name": "Demo Player",
|
|
||||||
"hostname": "player-001",
|
|
||||||
"group_id": 5,
|
|
||||||
"orientation": "Landscape",
|
|
||||||
"status": "online"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Player Configuration File
|
|
||||||
|
|
||||||
Players store their configuration in `player_config.ini`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[server]
|
|
||||||
server_url = http://your-server:5000
|
|
||||||
|
|
||||||
[player]
|
|
||||||
hostname = player-001
|
|
||||||
auth_code = abc123xyz...
|
|
||||||
player_id = 1
|
|
||||||
group_id = 5
|
|
||||||
|
|
||||||
[display]
|
|
||||||
orientation = Landscape
|
|
||||||
resolution = 1920x1080
|
|
||||||
|
|
||||||
[security]
|
|
||||||
verify_ssl = true
|
|
||||||
timeout = 30
|
|
||||||
|
|
||||||
[cache]
|
|
||||||
cache_dir = ./cache
|
|
||||||
max_cache_size = 1024
|
|
||||||
|
|
||||||
[logging]
|
|
||||||
enabled = true
|
|
||||||
log_level = INFO
|
|
||||||
log_file = ./player.log
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration with Kiwy-Signage
|
|
||||||
|
|
||||||
### Step 1: Copy Authentication Module
|
|
||||||
|
|
||||||
Copy `player_auth_module.py` to your Kiwy-Signage project:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp digiserver-v2/player_auth_module.py signage-player/src/player_auth.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Initialize Authentication
|
|
||||||
|
|
||||||
In your main signage player code:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from player_auth import PlayerAuth
|
|
||||||
|
|
||||||
# Initialize authentication
|
|
||||||
auth = PlayerAuth(config_path='player_config.ini')
|
|
||||||
|
|
||||||
# Check if already authenticated
|
|
||||||
if auth.is_authenticated():
|
|
||||||
# Verify saved credentials
|
|
||||||
valid, info = auth.verify_auth()
|
|
||||||
if valid:
|
|
||||||
print(f"Authenticated as: {info['player_name']}")
|
|
||||||
else:
|
|
||||||
# Re-authenticate
|
|
||||||
success, error = auth.authenticate(
|
|
||||||
hostname='player-001',
|
|
||||||
password='your_password'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# First time setup
|
|
||||||
hostname = input("Enter player hostname: ")
|
|
||||||
password = input("Enter password: ")
|
|
||||||
|
|
||||||
success, error = auth.authenticate(hostname, password)
|
|
||||||
if success:
|
|
||||||
print("Authentication successful!")
|
|
||||||
else:
|
|
||||||
print(f"Authentication failed: {error}")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Use Authentication for API Calls
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Get playlist with authentication
|
|
||||||
playlist = auth.get_playlist()
|
|
||||||
|
|
||||||
# Send heartbeat
|
|
||||||
auth.send_heartbeat(status='online')
|
|
||||||
|
|
||||||
# Make authenticated API request
|
|
||||||
import requests
|
|
||||||
|
|
||||||
auth_code = auth.get_auth_code()
|
|
||||||
player_id = auth.config.get('player', 'player_id')
|
|
||||||
server_url = auth.get_server_url()
|
|
||||||
|
|
||||||
response = requests.get(
|
|
||||||
f"{server_url}/api/playlists/{player_id}",
|
|
||||||
headers={'Authorization': f'Bearer {auth_code}'}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Server Setup
|
|
||||||
|
|
||||||
### 1. Create Players in DigiServer
|
|
||||||
|
|
||||||
Via Web Interface:
|
|
||||||
1. Log in as admin (admin/admin123)
|
|
||||||
2. Navigate to Players → Add Player
|
|
||||||
3. Fill in:
|
|
||||||
- **Name**: Display name
|
|
||||||
- **Hostname**: Unique identifier (e.g., `player-001`)
|
|
||||||
- **Location**: Physical location
|
|
||||||
- **Password**: Secure password
|
|
||||||
- **Quick Connect Code**: Optional easy pairing code
|
|
||||||
- **Orientation**: Landscape or Portrait
|
|
||||||
|
|
||||||
Via Python:
|
|
||||||
```python
|
|
||||||
from app.extensions import db
|
|
||||||
from app.models import Player
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
player = Player(
|
|
||||||
name='Office Player',
|
|
||||||
hostname='office-player-001',
|
|
||||||
location='Main Office - Reception',
|
|
||||||
auth_code=secrets.token_urlsafe(32),
|
|
||||||
orientation='Landscape'
|
|
||||||
)
|
|
||||||
player.set_password('secure_password_123')
|
|
||||||
player.set_quickconnect_code('OFFICE123')
|
|
||||||
|
|
||||||
db.session.add(player)
|
|
||||||
db.session.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Distribute Credentials
|
|
||||||
|
|
||||||
Securely provide each player with:
|
|
||||||
- Server URL
|
|
||||||
- Hostname
|
|
||||||
- Password OR Quick Connect Code
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
✅ **Passwords**: Hashed with bcrypt (cost factor 12)
|
|
||||||
✅ **Auth Codes**: 32-byte URL-safe tokens
|
|
||||||
✅ **HTTPS**: Enable SSL in production
|
|
||||||
✅ **Rate Limiting**: API endpoints protected (10 req/min for auth)
|
|
||||||
✅ **Local Storage**: Config file permissions should be 600
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Player Can't Authenticate
|
|
||||||
|
|
||||||
1. Check server connectivity:
|
|
||||||
```bash
|
|
||||||
curl http://your-server:5000/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Verify credentials in database
|
|
||||||
3. Check server logs for authentication attempts
|
|
||||||
4. Ensure hostname is unique
|
|
||||||
|
|
||||||
### Auth Code Invalid
|
|
||||||
|
|
||||||
1. Clear saved config: `rm player_config.ini`
|
|
||||||
2. Re-authenticate with password
|
|
||||||
3. Check if player was deleted from server
|
|
||||||
|
|
||||||
### Connection Timeout
|
|
||||||
|
|
||||||
1. Increase timeout in `player_config.ini`:
|
|
||||||
```ini
|
|
||||||
[security]
|
|
||||||
timeout = 60
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Check network connectivity
|
|
||||||
3. Verify server is running
|
|
||||||
|
|
||||||
## Migration from v1
|
|
||||||
|
|
||||||
If migrating from DigiServer v1:
|
|
||||||
|
|
||||||
1. **Export player data** from v1 database
|
|
||||||
2. **Create players** in v2 with hostname = old username
|
|
||||||
3. **Set passwords** using `player.set_password()`
|
|
||||||
4. **Update player apps** with new authentication module
|
|
||||||
5. **Test authentication** before full deployment
|
|
||||||
|
|
||||||
## Example: Complete Player Setup
|
|
||||||
|
|
||||||
```python
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Complete player setup example
|
|
||||||
"""
|
|
||||||
from player_auth import PlayerAuth
|
|
||||||
import sys
|
|
||||||
|
|
||||||
def setup_player():
|
|
||||||
"""Interactive player setup"""
|
|
||||||
auth = PlayerAuth()
|
|
||||||
|
|
||||||
# Check if already configured
|
|
||||||
if auth.is_authenticated():
|
|
||||||
print(f"✅ Already configured as: {auth.get_hostname()}")
|
|
||||||
|
|
||||||
# Test connection
|
|
||||||
valid, info = auth.verify_auth()
|
|
||||||
if valid:
|
|
||||||
print(f"✅ Connection successful")
|
|
||||||
print(f" Player: {info['player_name']}")
|
|
||||||
print(f" Group: {info.get('group_id', 'None')}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print("❌ Saved credentials invalid, reconfiguring...")
|
|
||||||
auth.clear_auth()
|
|
||||||
|
|
||||||
# First time setup
|
|
||||||
print("\n🚀 Player Setup")
|
|
||||||
print("-" * 50)
|
|
||||||
|
|
||||||
# Get server URL
|
|
||||||
server_url = input("Server URL [http://localhost:5000]: ").strip()
|
|
||||||
if server_url:
|
|
||||||
auth.config['server']['server_url'] = server_url
|
|
||||||
auth.save_config()
|
|
||||||
|
|
||||||
# Get hostname
|
|
||||||
hostname = input("Player hostname: ").strip()
|
|
||||||
if not hostname:
|
|
||||||
print("❌ Hostname required")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Authentication method
|
|
||||||
print("\nAuthentication method:")
|
|
||||||
print("1. Password")
|
|
||||||
print("2. Quick Connect Code")
|
|
||||||
choice = input("Choice [1]: ").strip() or "1"
|
|
||||||
|
|
||||||
if choice == "1":
|
|
||||||
password = input("Password: ").strip()
|
|
||||||
success, error = auth.authenticate(hostname, password=password)
|
|
||||||
else:
|
|
||||||
code = input("Quick Connect Code: ").strip()
|
|
||||||
success, error = auth.authenticate(hostname, quickconnect_code=code)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print("\n✅ Authentication successful!")
|
|
||||||
print(f" Config saved to: {auth.config_path}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f"\n❌ Authentication failed: {error}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
if setup_player():
|
|
||||||
sys.exit(0)
|
|
||||||
else:
|
|
||||||
sys.exit(1)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files Included
|
|
||||||
|
|
||||||
- `player_auth_module.py` - Python authentication module for players
|
|
||||||
- `player_config_template.ini` - Configuration template
|
|
||||||
- `reinit_db.sh` - Script to recreate database with new schema
|
|
||||||
- `PLAYER_AUTH.md` - This documentation
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions:
|
|
||||||
1. Check server logs: `app/instance/logs/`
|
|
||||||
2. Check player logs: `player.log`
|
|
||||||
3. Verify API health: `/api/health`
|
|
||||||
4. Review authentication attempts in server logs
|
|
||||||
282
PROGRESS.md
282
PROGRESS.md
@@ -1,282 +0,0 @@
|
|||||||
# Digiserver v2 - Blueprint Architecture Implementation Progress
|
|
||||||
|
|
||||||
## ✅ Completed Components
|
|
||||||
|
|
||||||
### Core Infrastructure (100%)
|
|
||||||
- ✅ **config.py** - Environment-based configuration (Development, Production, Testing)
|
|
||||||
- ✅ **extensions.py** - Centralized Flask extension initialization
|
|
||||||
- ✅ **app.py** - Application factory with blueprint registration
|
|
||||||
- ✅ **.env.example** - Environment variable template
|
|
||||||
- ✅ **.gitignore** - Git ignore patterns
|
|
||||||
- ✅ **requirements.txt** - Python dependencies with Flask 3.1.0, Flask-Caching, Redis
|
|
||||||
|
|
||||||
### Blueprints (100%)
|
|
||||||
All 6 blueprints completed with full functionality:
|
|
||||||
|
|
||||||
#### 1. **main.py** ✅
|
|
||||||
- Dashboard with caching (60s timeout)
|
|
||||||
- Health check endpoint with database and disk validation
|
|
||||||
- Server statistics display
|
|
||||||
|
|
||||||
#### 2. **auth.py** ✅
|
|
||||||
- Login with bcrypt password verification, remember me, next page redirect
|
|
||||||
- Logout with logging
|
|
||||||
- Register with validation (min 6 chars, username uniqueness check)
|
|
||||||
- Change password with current password verification
|
|
||||||
- Input sanitization and error handling
|
|
||||||
|
|
||||||
#### 3. **admin.py** ✅
|
|
||||||
- Admin panel with system overview (users, players, groups, content, storage)
|
|
||||||
- User management (create, change role, delete)
|
|
||||||
- Theme settings (light/dark mode)
|
|
||||||
- Logo upload functionality
|
|
||||||
- System logs management (view, clear)
|
|
||||||
- System information API endpoint (CPU, memory, disk)
|
|
||||||
- `admin_required` decorator for authorization
|
|
||||||
|
|
||||||
#### 4. **players.py** ✅
|
|
||||||
- Players list with status information
|
|
||||||
- Add player with auth code generation
|
|
||||||
- Edit player (name, location, group assignment)
|
|
||||||
- Delete player with cascade feedback deletion
|
|
||||||
- Regenerate auth code
|
|
||||||
- Player page with playlist and feedback
|
|
||||||
- Player fullscreen view with auth code verification
|
|
||||||
- Playlist caching (5 min timeout)
|
|
||||||
- Content reordering
|
|
||||||
- Bulk operations (delete players, assign to group)
|
|
||||||
|
|
||||||
#### 5. **groups.py** ✅
|
|
||||||
- Groups list with statistics
|
|
||||||
- Create group with content assignment
|
|
||||||
- Edit group (name, description, content)
|
|
||||||
- Delete group with player unassignment
|
|
||||||
- Manage group page (player status cards, content management)
|
|
||||||
- Group fullscreen view for monitoring
|
|
||||||
- Add/remove players from group
|
|
||||||
- Add/remove content from group
|
|
||||||
- Content reordering within group
|
|
||||||
- Group statistics API endpoint
|
|
||||||
|
|
||||||
#### 6. **content.py** ✅
|
|
||||||
- Content list with group information
|
|
||||||
- Upload content with progress tracking
|
|
||||||
- File type detection (image, video, PDF, presentation)
|
|
||||||
- Edit content metadata (duration, description)
|
|
||||||
- Delete content with file removal
|
|
||||||
- Bulk delete content
|
|
||||||
- Upload progress API endpoint
|
|
||||||
- Content preview/download
|
|
||||||
- Content statistics (total, by type, storage)
|
|
||||||
- Duplicate filename checker
|
|
||||||
- Content groups information API
|
|
||||||
|
|
||||||
#### 7. **api.py** ✅
|
|
||||||
- Health check endpoint
|
|
||||||
- Get player playlist (with auth and rate limiting)
|
|
||||||
- Player feedback submission
|
|
||||||
- Player status endpoint
|
|
||||||
- Upload progress tracking
|
|
||||||
- System information API
|
|
||||||
- List groups API
|
|
||||||
- List content API
|
|
||||||
- Server logs API with filtering
|
|
||||||
- Rate limiting decorator (60 req/min default)
|
|
||||||
- Player authentication via Bearer token
|
|
||||||
- API error handlers (404, 405, 500)
|
|
||||||
|
|
||||||
### Documentation (100%)
|
|
||||||
- ✅ **README.md** - Comprehensive project documentation
|
|
||||||
- ✅ Project structure diagram
|
|
||||||
- ✅ Quick start guide (development and Docker)
|
|
||||||
- ✅ Features list and optimization summary
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Pending Tasks
|
|
||||||
|
|
||||||
### Models (Priority: Critical)
|
|
||||||
Need to copy and adapt from v1 with improvements:
|
|
||||||
|
|
||||||
1. **User Model** - Add indexes on username
|
|
||||||
2. **Player Model** - Add indexes on auth_code, group_id, last_seen
|
|
||||||
3. **Group Model** - Add indexes on name
|
|
||||||
4. **Content Model** - Add indexes on content_type, position, uploaded_at
|
|
||||||
5. **ServerLog Model** - Add indexes on level, timestamp
|
|
||||||
6. **PlayerFeedback Model** - Add indexes on player_id, timestamp
|
|
||||||
7. **Association Tables** - group_content for many-to-many relationship
|
|
||||||
|
|
||||||
### Utils (Priority: Critical)
|
|
||||||
Need to copy and adapt from v1 with type hints:
|
|
||||||
|
|
||||||
1. **logger.py** - Logging utility with type hints
|
|
||||||
2. **uploads.py** - File upload utilities with progress tracking
|
|
||||||
3. **group_player_management.py** - Group/player management functions
|
|
||||||
4. **pptx_converter.py** - PowerPoint conversion utility
|
|
||||||
|
|
||||||
### Templates (Priority: High)
|
|
||||||
Need to copy from v1 and update route references:
|
|
||||||
|
|
||||||
1. Update all `url_for()` calls to use blueprint naming:
|
|
||||||
- `url_for('login')` → `url_for('auth.login')`
|
|
||||||
- `url_for('dashboard')` → `url_for('main.dashboard')`
|
|
||||||
- `url_for('add_player')` → `url_for('players.add_player')`
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
2. Organize templates into subdirectories:
|
|
||||||
- `auth/` - login.html, register.html
|
|
||||||
- `admin/` - admin.html
|
|
||||||
- `players/` - players_list.html, add_player.html, edit_player.html, player_page.html, player_fullscreen.html
|
|
||||||
- `groups/` - groups_list.html, create_group.html, edit_group.html, manage_group.html, group_fullscreen.html
|
|
||||||
- `content/` - content_list.html, upload_content.html, edit_content.html
|
|
||||||
- `errors/` - 404.html, 403.html, 500.html
|
|
||||||
- `base.html` - Main layout template
|
|
||||||
|
|
||||||
### Docker Configuration (Priority: High)
|
|
||||||
1. **Dockerfile** - Multi-stage build targeting 800MB
|
|
||||||
2. **docker-compose.yml** - Services: digiserver, redis, optional worker
|
|
||||||
3. **nginx.conf** - Reverse proxy configuration
|
|
||||||
4. **.dockerignore** - Docker ignore patterns
|
|
||||||
|
|
||||||
### Testing (Priority: Medium)
|
|
||||||
1. **tests/conftest.py** - Test fixtures
|
|
||||||
2. **tests/test_auth.py** - Authentication tests
|
|
||||||
3. **tests/test_players.py** - Player management tests
|
|
||||||
4. **tests/test_groups.py** - Group management tests
|
|
||||||
5. **tests/test_content.py** - Content management tests
|
|
||||||
6. **tests/test_api.py** - API endpoint tests
|
|
||||||
|
|
||||||
### Git Repository (Priority: Medium)
|
|
||||||
1. Initialize Git repository
|
|
||||||
2. Create initial commit
|
|
||||||
3. Push to remote (if desired)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Next Steps
|
|
||||||
|
|
||||||
### Immediate Actions
|
|
||||||
1. **Copy models from v1** - Add to `app/models/` with:
|
|
||||||
- Type hints
|
|
||||||
- Database indexes
|
|
||||||
- Proper relationships
|
|
||||||
- `__repr__` methods
|
|
||||||
|
|
||||||
2. **Copy utils from v1** - Add to `app/utils/` with:
|
|
||||||
- Type hints
|
|
||||||
- Improved error handling
|
|
||||||
- Documentation
|
|
||||||
|
|
||||||
3. **Create model init file** - `app/models/__init__.py` to export all models
|
|
||||||
|
|
||||||
### After Models/Utils
|
|
||||||
4. **Copy templates from v1** - Update route references to blueprint naming
|
|
||||||
5. **Create Docker configuration** - Multi-stage build with optimization
|
|
||||||
6. **Test application** - Ensure all routes work correctly
|
|
||||||
7. **Initialize Git** - Create repository and initial commit
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Progress Summary
|
|
||||||
|
|
||||||
| Component | Status | Progress |
|
|
||||||
|-----------|--------|----------|
|
|
||||||
| Core Infrastructure | ✅ Complete | 100% |
|
|
||||||
| Blueprints (6) | ✅ Complete | 100% |
|
|
||||||
| Documentation | ✅ Complete | 100% |
|
|
||||||
| Models | ⏳ Pending | 0% |
|
|
||||||
| Utils | ⏳ Pending | 0% |
|
|
||||||
| Templates | ⏳ Pending | 0% |
|
|
||||||
| Docker | ⏳ Pending | 0% |
|
|
||||||
| Testing | ⏳ Pending | 0% |
|
|
||||||
| Git | ⏳ Pending | 0% |
|
|
||||||
|
|
||||||
**Overall Progress: ~50%**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Key Features Implemented
|
|
||||||
|
|
||||||
### Blueprint Architecture
|
|
||||||
- ✅ Modular route organization (7 blueprints)
|
|
||||||
- ✅ URL prefixes for namespace separation
|
|
||||||
- ✅ Blueprint-specific error handlers in API
|
|
||||||
- ✅ Shared extension initialization
|
|
||||||
|
|
||||||
### Performance Optimizations
|
|
||||||
- ✅ Flask-Caching with Redis support
|
|
||||||
- ✅ Playlist caching (5 min timeout)
|
|
||||||
- ✅ Dashboard caching (60s timeout)
|
|
||||||
- ✅ Memoized functions with cache.memoize()
|
|
||||||
- ✅ Cache clearing on data changes
|
|
||||||
|
|
||||||
### Security Features
|
|
||||||
- ✅ Bcrypt password hashing
|
|
||||||
- ✅ Flask-Login session management
|
|
||||||
- ✅ `admin_required` decorator
|
|
||||||
- ✅ Player authentication via auth codes
|
|
||||||
- ✅ API Bearer token authentication
|
|
||||||
- ✅ Rate limiting on API endpoints
|
|
||||||
- ✅ Input validation and sanitization
|
|
||||||
- ✅ CSRF protection (Flask-WTF ready)
|
|
||||||
|
|
||||||
### API Features
|
|
||||||
- ✅ RESTful endpoints for players, groups, content
|
|
||||||
- ✅ Player playlist API with caching
|
|
||||||
- ✅ Player feedback submission
|
|
||||||
- ✅ Upload progress tracking
|
|
||||||
- ✅ System information API
|
|
||||||
- ✅ Rate limiting (60 req/min default)
|
|
||||||
- ✅ JSON error responses
|
|
||||||
- ✅ Health check endpoint
|
|
||||||
|
|
||||||
### Development Features
|
|
||||||
- ✅ Application factory pattern
|
|
||||||
- ✅ Environment-based configuration
|
|
||||||
- ✅ CLI commands (init-db, create-admin, seed-db)
|
|
||||||
- ✅ Comprehensive logging
|
|
||||||
- ✅ Context processors for templates
|
|
||||||
- ✅ Error handlers for common HTTP errors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Notes
|
|
||||||
|
|
||||||
### Architecture Decisions
|
|
||||||
- **Blueprint organization**: Logical separation by functionality
|
|
||||||
- **Caching strategy**: Redis for production, SimpleCache for development
|
|
||||||
- **Authentication**: Flask-Login for web, Bearer tokens for API
|
|
||||||
- **Rate limiting**: In-memory for now, should use Redis in production
|
|
||||||
- **File uploads**: Direct save with progress tracking, can add Celery for async processing
|
|
||||||
|
|
||||||
### Optimization Opportunities
|
|
||||||
- Add Celery for async video processing
|
|
||||||
- Implement WebSocket for real-time player updates
|
|
||||||
- Add comprehensive database migrations
|
|
||||||
- Implement API versioning (v1, v2)
|
|
||||||
- Add comprehensive test coverage
|
|
||||||
- Add Sentry for error tracking
|
|
||||||
|
|
||||||
### Migration from v1
|
|
||||||
When copying from v1, remember to:
|
|
||||||
- Update all route references to blueprint naming
|
|
||||||
- Add type hints to all functions
|
|
||||||
- Add database indexes for performance
|
|
||||||
- Update imports to match new structure
|
|
||||||
- Update static file paths in templates
|
|
||||||
- Test all functionality thoroughly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Ready for Next Phase
|
|
||||||
|
|
||||||
The blueprint architecture is now **fully implemented** with all 6 blueprints:
|
|
||||||
- ✅ main (dashboard, health)
|
|
||||||
- ✅ auth (login, register, logout)
|
|
||||||
- ✅ admin (user management, system settings)
|
|
||||||
- ✅ players (CRUD, fullscreen, bulk ops)
|
|
||||||
- ✅ groups (CRUD, manage, fullscreen)
|
|
||||||
- ✅ content (upload, manage, API)
|
|
||||||
- ✅ api (REST endpoints, authentication, rate limiting)
|
|
||||||
|
|
||||||
**Ready to proceed with models and utils migration!**
|
|
||||||
287
README.md
287
README.md
@@ -1,287 +0,0 @@
|
|||||||
# DigiServer v2 - Blueprint Architecture
|
|
||||||
|
|
||||||
Modern Flask application with blueprint architecture, designed for scalability and maintainability.
|
|
||||||
|
|
||||||
## 🎯 Project Goals
|
|
||||||
|
|
||||||
- **Modular Architecture**: Blueprints for better code organization
|
|
||||||
- **Scalability**: Redis caching, Celery background tasks
|
|
||||||
- **Security**: Input validation, rate limiting, CSRF protection
|
|
||||||
- **Performance**: Optimized Docker image, database indexes
|
|
||||||
- **Maintainability**: Type hints, comprehensive tests, clear documentation
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
digiserver-v2/
|
|
||||||
├── app/
|
|
||||||
│ ├── app.py # Application factory
|
|
||||||
│ ├── config.py # Environment-based configuration
|
|
||||||
│ ├── extensions.py # Flask extensions
|
|
||||||
│ ├── blueprints/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── main.py # Dashboard & home
|
|
||||||
│ │ ├── auth.py # Authentication
|
|
||||||
│ │ ├── admin.py # Admin panel
|
|
||||||
│ │ ├── players.py # Player management
|
|
||||||
│ │ ├── groups.py # Group management
|
|
||||||
│ │ ├── content.py # Media upload & management
|
|
||||||
│ │ └── api.py # REST API endpoints
|
|
||||||
│ ├── models/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── user.py
|
|
||||||
│ │ ├── player.py
|
|
||||||
│ │ ├── group.py
|
|
||||||
│ │ ├── content.py
|
|
||||||
│ │ ├── player_feedback.py
|
|
||||||
│ │ └── server_log.py
|
|
||||||
│ ├── utils/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── logger.py
|
|
||||||
│ │ ├── uploads.py
|
|
||||||
│ │ ├── decorators.py
|
|
||||||
│ │ └── validators.py
|
|
||||||
│ ├── templates/
|
|
||||||
│ │ ├── base.html
|
|
||||||
│ │ ├── auth/
|
|
||||||
│ │ ├── admin/
|
|
||||||
│ │ ├── players/
|
|
||||||
│ │ ├── groups/
|
|
||||||
│ │ └── errors/
|
|
||||||
│ └── static/
|
|
||||||
│ ├── css/
|
|
||||||
│ ├── js/
|
|
||||||
│ ├── uploads/
|
|
||||||
│ └── resurse/
|
|
||||||
├── tests/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── conftest.py
|
|
||||||
│ ├── test_auth.py
|
|
||||||
│ ├── test_players.py
|
|
||||||
│ └── test_api.py
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── Dockerfile
|
|
||||||
├── requirements.txt
|
|
||||||
├── .env.example
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone the repository
|
|
||||||
git clone <repository-url>
|
|
||||||
cd digiserver-v2
|
|
||||||
|
|
||||||
# Create virtual environment
|
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Set up environment variables
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env with your settings
|
|
||||||
|
|
||||||
# Initialize database
|
|
||||||
flask init-db
|
|
||||||
flask create-admin
|
|
||||||
|
|
||||||
# Run development server
|
|
||||||
flask run
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker (Production)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build and start containers
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
# Stop containers
|
|
||||||
docker compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Configuration
|
|
||||||
|
|
||||||
Configuration is environment-based (development, production, testing).
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
Create a `.env` file:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Flask
|
|
||||||
FLASK_ENV=production
|
|
||||||
SECRET_KEY=your-secret-key-here
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL=sqlite:///instance/dashboard.db
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_HOST=redis
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
# Admin Defaults
|
|
||||||
ADMIN_USER=admin
|
|
||||||
ADMIN_PASSWORD=secure-password
|
|
||||||
|
|
||||||
# Optional
|
|
||||||
SENTRY_DSN=your-sentry-dsn
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Features
|
|
||||||
|
|
||||||
### Current (v2.0.0)
|
|
||||||
|
|
||||||
- ✅ Blueprint architecture
|
|
||||||
- ✅ Environment-based configuration
|
|
||||||
- ✅ User authentication & authorization
|
|
||||||
- ✅ Admin panel
|
|
||||||
- ✅ Player management
|
|
||||||
- ✅ Group management
|
|
||||||
- ✅ Content upload & management
|
|
||||||
- ✅ REST API
|
|
||||||
- ✅ Redis caching (production)
|
|
||||||
- ✅ Health check endpoint
|
|
||||||
|
|
||||||
### Planned
|
|
||||||
|
|
||||||
- ⏳ Celery background tasks
|
|
||||||
- ⏳ Rate limiting
|
|
||||||
- ⏳ API authentication (JWT)
|
|
||||||
- ⏳ Unit & integration tests
|
|
||||||
- ⏳ API documentation (Swagger)
|
|
||||||
- ⏳ Monitoring & metrics (Prometheus)
|
|
||||||
|
|
||||||
## 🛠️ Development
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest
|
|
||||||
pytest --cov=app tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Migrations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create migration
|
|
||||||
flask db migrate -m "Description"
|
|
||||||
|
|
||||||
# Apply migration
|
|
||||||
flask db upgrade
|
|
||||||
|
|
||||||
# Rollback
|
|
||||||
flask db downgrade
|
|
||||||
```
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Format code
|
|
||||||
black app/
|
|
||||||
|
|
||||||
# Lint
|
|
||||||
flake8 app/
|
|
||||||
pylint app/
|
|
||||||
|
|
||||||
# Type check
|
|
||||||
mypy app/
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📖 API Documentation
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
All API endpoints require authentication via session or API key.
|
|
||||||
|
|
||||||
### Endpoints
|
|
||||||
|
|
||||||
- `GET /api/playlists` - Get playlist for player
|
|
||||||
- `POST /api/player-feedback` - Submit player feedback
|
|
||||||
- `GET /health` - Health check
|
|
||||||
|
|
||||||
See `/docs` for full API documentation (Swagger UI).
|
|
||||||
|
|
||||||
## 🔒 Security
|
|
||||||
|
|
||||||
- CSRF protection enabled
|
|
||||||
- Rate limiting on API endpoints
|
|
||||||
- Input validation using Marshmallow
|
|
||||||
- SQL injection prevention (SQLAlchemy ORM)
|
|
||||||
- XSS prevention (Jinja2 autoescaping)
|
|
||||||
- Secure password hashing (bcrypt)
|
|
||||||
|
|
||||||
## 📈 Performance
|
|
||||||
|
|
||||||
- Redis caching for frequently accessed data
|
|
||||||
- Database indexes on foreign keys
|
|
||||||
- Lazy loading for relationships
|
|
||||||
- Static file compression (nginx)
|
|
||||||
- Multi-stage Docker build (~800MB)
|
|
||||||
|
|
||||||
## 🐳 Docker
|
|
||||||
|
|
||||||
### Multi-stage Build
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# Build stage (heavy dependencies)
|
|
||||||
FROM python:3.11-slim as builder
|
|
||||||
# ... build dependencies
|
|
||||||
|
|
||||||
# Runtime stage (slim)
|
|
||||||
FROM python:3.11-slim
|
|
||||||
# ... only runtime dependencies
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Compose Services
|
|
||||||
|
|
||||||
- **digiserver**: Main Flask application
|
|
||||||
- **redis**: Cache and session storage
|
|
||||||
- **worker**: Celery background worker (optional)
|
|
||||||
- **nginx**: Reverse proxy (production)
|
|
||||||
|
|
||||||
## 📝 Migration from v1
|
|
||||||
|
|
||||||
See `MIGRATION.md` for detailed migration guide from digiserver v1.
|
|
||||||
|
|
||||||
Key differences:
|
|
||||||
- Blueprint-based routing instead of monolithic app.py
|
|
||||||
- Environment-based configuration
|
|
||||||
- Redis caching in production
|
|
||||||
- Improved error handling
|
|
||||||
- Type hints throughout codebase
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
||||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
||||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
||||||
5. Open a Pull Request
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
This project is part of the DigiServer digital signage system.
|
|
||||||
|
|
||||||
## 🙏 Acknowledgments
|
|
||||||
|
|
||||||
- Built with Flask and modern Python practices
|
|
||||||
- Inspired by Flask best practices and 12-factor app principles
|
|
||||||
- Based on lessons learned from DigiServer v1
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
For issues and feature requests, please use the GitHub issue tracker.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Status**: 🚧 Work in Progress - Blueprint architecture implementation
|
|
||||||
**Version**: 2.0.0-alpha
|
|
||||||
**Last Updated**: 2025-11-12
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""
|
|
||||||
Add orientation column to playlist table
|
|
||||||
Run this script to update the database schema
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Add parent directory to path
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
from app.app import app
|
|
||||||
from app.extensions import db
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
def add_orientation_column():
|
|
||||||
"""Add orientation column to playlist table."""
|
|
||||||
with app.app_context():
|
|
||||||
try:
|
|
||||||
# Check if column exists
|
|
||||||
result = db.session.execute(text("PRAGMA table_info(playlist)"))
|
|
||||||
columns = [row[1] for row in result]
|
|
||||||
|
|
||||||
if 'orientation' in columns:
|
|
||||||
print("✅ Column 'orientation' already exists in playlist table")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Add the column
|
|
||||||
print("Adding 'orientation' column to playlist table...")
|
|
||||||
db.session.execute(text("""
|
|
||||||
ALTER TABLE playlist
|
|
||||||
ADD COLUMN orientation VARCHAR(20) DEFAULT 'Landscape' NOT NULL
|
|
||||||
"""))
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
print("✅ Successfully added 'orientation' column to playlist table")
|
|
||||||
print(" Default value: 'Landscape'")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Error adding column: {str(e)}")
|
|
||||||
db.session.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
add_orientation_column()
|
|
||||||
@@ -8,7 +8,7 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.extensions import db, bcrypt
|
from app.extensions import db, bcrypt
|
||||||
from app.models import User, Player, Group, Content, ServerLog
|
from app.models import User, Player, Group, Content, ServerLog, Playlist
|
||||||
from app.utils.logger import log_action
|
from app.utils.logger import log_action
|
||||||
|
|
||||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
@@ -38,7 +38,7 @@ def admin_panel():
|
|||||||
# Get statistics
|
# Get statistics
|
||||||
total_users = User.query.count()
|
total_users = User.query.count()
|
||||||
total_players = Player.query.count()
|
total_players = Player.query.count()
|
||||||
total_groups = Group.query.count()
|
total_playlists = Playlist.query.count()
|
||||||
total_content = Content.query.count()
|
total_content = Content.query.count()
|
||||||
|
|
||||||
# Get recent logs
|
# Get recent logs
|
||||||
@@ -62,7 +62,7 @@ def admin_panel():
|
|||||||
return render_template('admin/admin.html',
|
return render_template('admin/admin.html',
|
||||||
total_users=total_users,
|
total_users=total_users,
|
||||||
total_players=total_players,
|
total_players=total_players,
|
||||||
total_groups=total_groups,
|
total_playlists=total_playlists,
|
||||||
total_content=total_content,
|
total_content=total_content,
|
||||||
storage_mb=storage_mb,
|
storage_mb=storage_mb,
|
||||||
users=users,
|
users=users,
|
||||||
@@ -347,3 +347,146 @@ def system_info():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_action('error', f'Error getting system info: {str(e)}')
|
log_action('error', f'Error getting system info: {str(e)}')
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/leftover-media')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def leftover_media():
|
||||||
|
"""Display leftover media files not assigned to any playlist."""
|
||||||
|
from app.models.playlist import playlist_content
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get all content IDs that are in playlists
|
||||||
|
stmt = select(playlist_content.c.content_id).distinct()
|
||||||
|
content_in_playlists = set(row[0] for row in db.session.execute(stmt))
|
||||||
|
|
||||||
|
# Get all content
|
||||||
|
all_content = Content.query.all()
|
||||||
|
|
||||||
|
# Filter content not in any playlist
|
||||||
|
leftover_content = [c for c in all_content if c.id not in content_in_playlists]
|
||||||
|
|
||||||
|
# Separate by type
|
||||||
|
leftover_images = [c for c in leftover_content if c.content_type == 'image']
|
||||||
|
leftover_videos = [c for c in leftover_content if c.content_type == 'video']
|
||||||
|
leftover_pdfs = [c for c in leftover_content if c.content_type == 'pdf']
|
||||||
|
leftover_pptx = [c for c in leftover_content if c.content_type == 'pptx']
|
||||||
|
|
||||||
|
# Calculate storage
|
||||||
|
total_leftover_size = sum(c.file_size for c in leftover_content)
|
||||||
|
images_size = sum(c.file_size for c in leftover_images)
|
||||||
|
videos_size = sum(c.file_size for c in leftover_videos)
|
||||||
|
pdfs_size = sum(c.file_size for c in leftover_pdfs)
|
||||||
|
pptx_size = sum(c.file_size for c in leftover_pptx)
|
||||||
|
|
||||||
|
return render_template('admin/leftover_media.html',
|
||||||
|
leftover_images=leftover_images,
|
||||||
|
leftover_videos=leftover_videos,
|
||||||
|
leftover_pdfs=leftover_pdfs,
|
||||||
|
leftover_pptx=leftover_pptx,
|
||||||
|
total_leftover=len(leftover_content),
|
||||||
|
total_leftover_size_mb=total_leftover_size / (1024 * 1024),
|
||||||
|
images_size_mb=images_size / (1024 * 1024),
|
||||||
|
videos_size_mb=videos_size / (1024 * 1024),
|
||||||
|
pdfs_size_mb=pdfs_size / (1024 * 1024),
|
||||||
|
pptx_size_mb=pptx_size / (1024 * 1024))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Error loading leftover media: {str(e)}')
|
||||||
|
flash('Error loading leftover media.', 'danger')
|
||||||
|
return redirect(url_for('admin.admin_panel'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/delete-leftover-images', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete_leftover_images():
|
||||||
|
"""Delete all leftover images that are not part of any playlist"""
|
||||||
|
from app.models.playlist import playlist_content
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find all leftover image content
|
||||||
|
leftover_images = db.session.query(Content).filter(
|
||||||
|
Content.media_type == 'image',
|
||||||
|
~Content.id.in_(
|
||||||
|
db.session.query(playlist_content.c.content_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for content in leftover_images:
|
||||||
|
try:
|
||||||
|
# Delete physical file
|
||||||
|
if content.file_path:
|
||||||
|
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
# Delete database record
|
||||||
|
db.session.delete(content)
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Error deleting {content.file_path}: {str(e)}")
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
flash(f'Deleted {deleted_count} images with {len(errors)} errors', 'warning')
|
||||||
|
else:
|
||||||
|
flash(f'Successfully deleted {deleted_count} leftover images', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error deleting leftover images: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.leftover_media'))
|
||||||
|
|
||||||
|
@admin_bp.route('/delete-leftover-videos', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def delete_leftover_videos():
|
||||||
|
"""Delete all leftover videos that are not part of any playlist"""
|
||||||
|
from app.models.playlist import playlist_content
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find all leftover video content
|
||||||
|
leftover_videos = db.session.query(Content).filter(
|
||||||
|
Content.media_type == 'video',
|
||||||
|
~Content.id.in_(
|
||||||
|
db.session.query(playlist_content.c.content_id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for content in leftover_videos:
|
||||||
|
try:
|
||||||
|
# Delete physical file
|
||||||
|
if content.file_path:
|
||||||
|
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
# Delete database record
|
||||||
|
db.session.delete(content)
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Error deleting {content.file_path}: {str(e)}")
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
flash(f'Deleted {deleted_count} videos with {len(errors)} errors', 'warning')
|
||||||
|
else:
|
||||||
|
flash(f'Successfully deleted {deleted_count} leftover videos', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Error deleting leftover videos: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.leftover_media'))
|
||||||
|
|||||||
@@ -243,10 +243,222 @@ def upload_media_page():
|
|||||||
return render_template('content/upload_media.html', playlists=playlists)
|
return render_template('content/upload_media.html', playlists=playlists)
|
||||||
|
|
||||||
|
|
||||||
|
def process_image_file(filepath: str, filename: str) -> tuple[bool, str]:
|
||||||
|
"""Process and optimize image files."""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Open and optimize image
|
||||||
|
img = Image.open(filepath)
|
||||||
|
|
||||||
|
# Convert RGBA to RGB for JPEGs
|
||||||
|
if img.mode == 'RGBA' and filename.lower().endswith(('.jpg', '.jpeg')):
|
||||||
|
rgb_img = Image.new('RGB', img.size, (255, 255, 255))
|
||||||
|
rgb_img.paste(img, mask=img.split()[3])
|
||||||
|
img = rgb_img
|
||||||
|
|
||||||
|
# Resize if too large (max 1920x1080 for display efficiency)
|
||||||
|
max_size = (1920, 1080)
|
||||||
|
if img.width > max_size[0] or img.height > max_size[1]:
|
||||||
|
img.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||||
|
img.save(filepath, optimize=True, quality=85)
|
||||||
|
log_action('info', f'Optimized image: {filename}')
|
||||||
|
|
||||||
|
return True, "Image processed successfully"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Image processing error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def process_video_file_extended(filepath: str, filename: str) -> tuple[bool, str]:
|
||||||
|
"""Process and optimize video files for Raspberry Pi playback."""
|
||||||
|
try:
|
||||||
|
# Basic video validation
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Check if video is playable
|
||||||
|
result = subprocess.run(
|
||||||
|
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
|
||||||
|
'-show_entries', 'stream=codec_name,width,height',
|
||||||
|
'-of', 'default=noprint_wrappers=1', filepath],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
log_action('info', f'Video validated: {filename}')
|
||||||
|
return True, "Video validated successfully"
|
||||||
|
else:
|
||||||
|
return False, "Video validation failed"
|
||||||
|
except Exception as e:
|
||||||
|
# If ffprobe not available, just accept the video
|
||||||
|
log_action('warning', f'Video validation skipped (ffprobe unavailable): {filename}')
|
||||||
|
return True, "Video accepted without validation"
|
||||||
|
|
||||||
|
|
||||||
|
def process_pdf_file(filepath: str, filename: str) -> tuple[bool, str]:
|
||||||
|
"""Process PDF files."""
|
||||||
|
try:
|
||||||
|
# Basic PDF validation - check if it's a valid PDF
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
header = f.read(5)
|
||||||
|
if header != b'%PDF-':
|
||||||
|
return False, "Invalid PDF file"
|
||||||
|
|
||||||
|
log_action('info', f'PDF validated: {filename}')
|
||||||
|
return True, "PDF processed successfully"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"PDF processing error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
|
||||||
|
"""Process PowerPoint presentation files by converting slides to images."""
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Basic validation - check file exists and has content
|
||||||
|
file_size = os.path.getsize(filepath)
|
||||||
|
if file_size < 1024: # Less than 1KB is suspicious
|
||||||
|
return False, "File too small to be a valid presentation"
|
||||||
|
|
||||||
|
# Check if LibreOffice is available
|
||||||
|
libreoffice_paths = [
|
||||||
|
'/usr/bin/libreoffice',
|
||||||
|
'/usr/bin/soffice',
|
||||||
|
'/snap/bin/libreoffice',
|
||||||
|
'libreoffice', # Try in PATH
|
||||||
|
'soffice'
|
||||||
|
]
|
||||||
|
|
||||||
|
libreoffice_cmd = None
|
||||||
|
for cmd in libreoffice_paths:
|
||||||
|
try:
|
||||||
|
result = subprocess.run([cmd, '--version'],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
libreoffice_cmd = cmd
|
||||||
|
log_action('info', f'Found LibreOffice at: {cmd}')
|
||||||
|
break
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not libreoffice_cmd:
|
||||||
|
log_action('warning', f'LibreOffice not found, skipping slide conversion for: {filename}')
|
||||||
|
return True, "Presentation accepted without conversion (LibreOffice unavailable)"
|
||||||
|
|
||||||
|
# Create temporary directory for conversion
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
temp_path = Path(temp_dir)
|
||||||
|
|
||||||
|
# Copy presentation to temp directory
|
||||||
|
temp_ppt = temp_path / filename
|
||||||
|
shutil.copy2(filepath, temp_ppt)
|
||||||
|
|
||||||
|
# Convert presentation to images (PNG format)
|
||||||
|
# Using LibreOffice headless mode with custom resolution
|
||||||
|
convert_cmd = [
|
||||||
|
libreoffice_cmd,
|
||||||
|
'--headless',
|
||||||
|
'--convert-to', 'png',
|
||||||
|
'--outdir', str(temp_path),
|
||||||
|
str(temp_ppt)
|
||||||
|
]
|
||||||
|
|
||||||
|
log_action('info', f'Converting presentation to images: {filename}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
convert_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120 # 2 minutes timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
log_action('error', f'LibreOffice conversion failed: {result.stderr}')
|
||||||
|
return True, "Presentation accepted without conversion (conversion failed)"
|
||||||
|
|
||||||
|
# Find generated PNG files
|
||||||
|
png_files = sorted(temp_path.glob('*.png'))
|
||||||
|
|
||||||
|
if not png_files:
|
||||||
|
log_action('warning', f'No images generated from presentation: {filename}')
|
||||||
|
return True, "Presentation accepted without images"
|
||||||
|
|
||||||
|
# Get upload folder from app config
|
||||||
|
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||||
|
base_name = os.path.splitext(filename)[0]
|
||||||
|
|
||||||
|
# Move converted images to upload folder
|
||||||
|
slide_count = 0
|
||||||
|
for idx, png_file in enumerate(png_files, start=1):
|
||||||
|
# Create descriptive filename
|
||||||
|
slide_filename = f"{base_name}_slide_{idx:03d}.png"
|
||||||
|
destination = os.path.join(upload_folder, slide_filename)
|
||||||
|
|
||||||
|
shutil.move(str(png_file), destination)
|
||||||
|
|
||||||
|
# Optimize the image to Full HD (1920x1080)
|
||||||
|
optimize_image_to_fullhd(destination)
|
||||||
|
|
||||||
|
slide_count += 1
|
||||||
|
|
||||||
|
log_action('info', f'Converted {slide_count} slides from {filename} to images')
|
||||||
|
|
||||||
|
# Remove original PPTX file as we now have the images
|
||||||
|
os.remove(filepath)
|
||||||
|
|
||||||
|
return True, f"Presentation converted to {slide_count} Full HD images"
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
log_action('error', f'LibreOffice conversion timeout for: {filename}')
|
||||||
|
return True, "Presentation accepted without conversion (timeout)"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Presentation processing error: {str(e)}')
|
||||||
|
return False, f"Presentation processing error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def optimize_image_to_fullhd(filepath: str) -> bool:
|
||||||
|
"""Optimize and resize image to Full HD (1920x1080) maintaining aspect ratio."""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
img = Image.open(filepath)
|
||||||
|
|
||||||
|
# Target Full HD resolution
|
||||||
|
target_size = (1920, 1080)
|
||||||
|
|
||||||
|
# Calculate resize maintaining aspect ratio
|
||||||
|
img.thumbnail(target_size, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Create Full HD canvas with white background
|
||||||
|
fullhd_img = Image.new('RGB', target_size, (255, 255, 255))
|
||||||
|
|
||||||
|
# Center the image on the canvas
|
||||||
|
x = (target_size[0] - img.width) // 2
|
||||||
|
y = (target_size[1] - img.height) // 2
|
||||||
|
|
||||||
|
if img.mode == 'RGBA':
|
||||||
|
fullhd_img.paste(img, (x, y), img)
|
||||||
|
else:
|
||||||
|
fullhd_img.paste(img, (x, y))
|
||||||
|
|
||||||
|
# Save optimized image
|
||||||
|
fullhd_img.save(filepath, 'PNG', optimize=True)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Image optimization error: {str(e)}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/upload-media', methods=['POST'])
|
@content_bp.route('/upload-media', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def upload_media():
|
def upload_media():
|
||||||
"""Upload media files to library."""
|
"""Upload media files to library with type-specific processing."""
|
||||||
try:
|
try:
|
||||||
files = request.files.getlist('files')
|
files = request.files.getlist('files')
|
||||||
content_type = request.form.get('content_type', 'image')
|
content_type = request.form.get('content_type', 'image')
|
||||||
@@ -261,6 +473,7 @@ def upload_media():
|
|||||||
os.makedirs(upload_folder, exist_ok=True)
|
os.makedirs(upload_folder, exist_ok=True)
|
||||||
|
|
||||||
uploaded_count = 0
|
uploaded_count = 0
|
||||||
|
processing_errors = []
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
if file.filename == '':
|
if file.filename == '':
|
||||||
@@ -275,64 +488,138 @@ def upload_media():
|
|||||||
log_action('warning', f'File {filename} already exists, skipping')
|
log_action('warning', f'File {filename} already exists, skipping')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Save file
|
# Save file first
|
||||||
file.save(filepath)
|
file.save(filepath)
|
||||||
|
|
||||||
# Determine content type from extension
|
# Determine content type from extension
|
||||||
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||||
|
|
||||||
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
|
# Process file based on type
|
||||||
|
processing_success = True
|
||||||
|
processing_message = ""
|
||||||
|
|
||||||
|
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']:
|
||||||
detected_type = 'image'
|
detected_type = 'image'
|
||||||
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
processing_success, processing_message = process_image_file(filepath, filename)
|
||||||
|
|
||||||
|
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']:
|
||||||
detected_type = 'video'
|
detected_type = 'video'
|
||||||
# Process video for Raspberry Pi
|
processing_success, processing_message = process_video_file_extended(filepath, filename)
|
||||||
success, message = process_video_file(filepath, os.urandom(8).hex())
|
|
||||||
if not success:
|
|
||||||
log_action('error', f'Video processing failed: {message}')
|
|
||||||
elif file_ext == 'pdf':
|
elif file_ext == 'pdf':
|
||||||
detected_type = 'pdf'
|
detected_type = 'pdf'
|
||||||
|
processing_success, processing_message = process_pdf_file(filepath, filename)
|
||||||
|
|
||||||
|
elif file_ext in ['ppt', 'pptx']:
|
||||||
|
detected_type = 'pptx'
|
||||||
|
processing_success, processing_message = process_presentation_file(filepath, filename)
|
||||||
|
|
||||||
|
# For presentations, slides are converted to individual images
|
||||||
|
# We need to add each slide image as a separate content item
|
||||||
|
if processing_success and "converted to" in processing_message.lower():
|
||||||
|
# Find all slide images that were created
|
||||||
|
base_name = os.path.splitext(filename)[0]
|
||||||
|
slide_pattern = f"{base_name}_slide_*.png"
|
||||||
|
import glob
|
||||||
|
slide_files = sorted(glob.glob(os.path.join(upload_folder, slide_pattern)))
|
||||||
|
|
||||||
|
if slide_files:
|
||||||
|
max_position = 0
|
||||||
|
if playlist_id:
|
||||||
|
playlist = Playlist.query.get(playlist_id)
|
||||||
|
max_position = db.session.query(db.func.max(playlist_content.c.position))\
|
||||||
|
.filter(playlist_content.c.playlist_id == playlist_id)\
|
||||||
|
.scalar() or 0
|
||||||
|
|
||||||
|
# Add each slide as separate content
|
||||||
|
for slide_file in slide_files:
|
||||||
|
slide_filename = os.path.basename(slide_file)
|
||||||
|
|
||||||
|
# Create content record for slide
|
||||||
|
slide_content = Content(
|
||||||
|
filename=slide_filename,
|
||||||
|
content_type='image',
|
||||||
|
duration=duration,
|
||||||
|
file_size=os.path.getsize(slide_file)
|
||||||
|
)
|
||||||
|
db.session.add(slide_content)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Add to playlist if specified
|
||||||
|
if playlist_id:
|
||||||
|
max_position += 1
|
||||||
|
stmt = playlist_content.insert().values(
|
||||||
|
playlist_id=playlist_id,
|
||||||
|
content_id=slide_content.id,
|
||||||
|
position=max_position,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
uploaded_count += 1
|
||||||
|
|
||||||
|
# Increment playlist version if slides were added
|
||||||
|
if playlist_id and slide_files:
|
||||||
|
playlist.version += 1
|
||||||
|
|
||||||
|
continue # Skip normal content creation below
|
||||||
|
|
||||||
else:
|
else:
|
||||||
detected_type = 'other'
|
detected_type = 'other'
|
||||||
|
|
||||||
# Create content record
|
if not processing_success:
|
||||||
content = Content(
|
processing_errors.append(f"{filename}: {processing_message}")
|
||||||
filename=filename,
|
if os.path.exists(filepath):
|
||||||
content_type=detected_type,
|
os.remove(filepath) # Remove failed file
|
||||||
duration=duration,
|
log_action('error', f'Processing failed for {filename}: {processing_message}')
|
||||||
file_size=os.path.getsize(filepath)
|
continue
|
||||||
)
|
|
||||||
db.session.add(content)
|
|
||||||
db.session.flush() # Get content ID
|
|
||||||
|
|
||||||
# Add to playlist if specified
|
# Create content record (for non-presentation files or failed conversions)
|
||||||
if playlist_id:
|
if os.path.exists(filepath):
|
||||||
playlist = Playlist.query.get(playlist_id)
|
content = Content(
|
||||||
if playlist:
|
filename=filename,
|
||||||
# Get max position
|
content_type=detected_type,
|
||||||
max_position = db.session.query(db.func.max(playlist_content.c.position))\
|
duration=duration,
|
||||||
.filter(playlist_content.c.playlist_id == playlist_id)\
|
file_size=os.path.getsize(filepath)
|
||||||
.scalar() or 0
|
)
|
||||||
|
db.session.add(content)
|
||||||
|
db.session.flush() # Get content ID
|
||||||
|
|
||||||
# Add to playlist
|
# Add to playlist if specified
|
||||||
stmt = playlist_content.insert().values(
|
if playlist_id:
|
||||||
playlist_id=playlist_id,
|
playlist = Playlist.query.get(playlist_id)
|
||||||
content_id=content.id,
|
if playlist:
|
||||||
position=max_position + 1,
|
# Get max position
|
||||||
duration=duration
|
max_position = db.session.query(db.func.max(playlist_content.c.position))\
|
||||||
)
|
.filter(playlist_content.c.playlist_id == playlist_id)\
|
||||||
db.session.execute(stmt)
|
.scalar() or 0
|
||||||
|
|
||||||
# Increment playlist version
|
# Add to playlist
|
||||||
playlist.version += 1
|
stmt = playlist_content.insert().values(
|
||||||
|
playlist_id=playlist_id,
|
||||||
|
content_id=content.id,
|
||||||
|
position=max_position + 1,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
uploaded_count += 1
|
# Increment playlist version
|
||||||
|
playlist.version += 1
|
||||||
|
|
||||||
|
uploaded_count += 1
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
cache.clear()
|
cache.clear()
|
||||||
|
|
||||||
log_action('info', f'Uploaded {uploaded_count} media files')
|
log_action('info', f'Uploaded {uploaded_count} media files')
|
||||||
|
|
||||||
if playlist_id:
|
# Show appropriate flash message
|
||||||
|
if processing_errors:
|
||||||
|
error_summary = '; '.join(processing_errors[:3])
|
||||||
|
if len(processing_errors) > 3:
|
||||||
|
error_summary += f' and {len(processing_errors) - 3} more...'
|
||||||
|
flash(f'Uploaded {uploaded_count} file(s). Errors: {error_summary}', 'warning')
|
||||||
|
elif playlist_id:
|
||||||
playlist = Playlist.query.get(playlist_id)
|
playlist = Playlist.query.get(playlist_id)
|
||||||
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')
|
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -213,31 +213,8 @@ def regenerate_auth_code(player_id: int):
|
|||||||
@players_bp.route('/<int:player_id>')
|
@players_bp.route('/<int:player_id>')
|
||||||
@login_required
|
@login_required
|
||||||
def player_page(player_id: int):
|
def player_page(player_id: int):
|
||||||
"""Display player page with content and controls."""
|
"""Redirect to manage player page (combined view)."""
|
||||||
try:
|
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||||
player = Player.query.get_or_404(player_id)
|
|
||||||
|
|
||||||
# Get player's playlist
|
|
||||||
playlist = get_player_playlist(player_id)
|
|
||||||
|
|
||||||
# Get player status
|
|
||||||
status_info = get_player_status_info(player_id)
|
|
||||||
|
|
||||||
# Get recent feedback
|
|
||||||
recent_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\
|
|
||||||
.order_by(PlayerFeedback.timestamp.desc())\
|
|
||||||
.limit(10)\
|
|
||||||
.all()
|
|
||||||
|
|
||||||
return render_template('players/player_page.html',
|
|
||||||
player=player,
|
|
||||||
playlist=playlist,
|
|
||||||
status_info=status_info,
|
|
||||||
recent_feedback=recent_feedback)
|
|
||||||
except Exception as e:
|
|
||||||
log_action('error', f'Error loading player page: {str(e)}')
|
|
||||||
flash('Error loading player page.', 'danger')
|
|
||||||
return redirect(url_for('players.list'))
|
|
||||||
|
|
||||||
|
|
||||||
@players_bp.route('/<int:player_id>/manage', methods=['GET', 'POST'])
|
@players_bp.route('/<int:player_id>/manage', methods=['GET', 'POST'])
|
||||||
@@ -347,7 +324,7 @@ def player_fullscreen(player_id: int):
|
|||||||
|
|
||||||
@cache.memoize(timeout=300) # Cache for 5 minutes
|
@cache.memoize(timeout=300) # Cache for 5 minutes
|
||||||
def get_player_playlist(player_id: int) -> List[dict]:
|
def get_player_playlist(player_id: int) -> List[dict]:
|
||||||
"""Get playlist for a player based on their direct content assignment.
|
"""Get playlist for a player based on their assigned playlist.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
player_id: The player's database ID
|
player_id: The player's database ID
|
||||||
@@ -356,23 +333,26 @@ def get_player_playlist(player_id: int) -> List[dict]:
|
|||||||
List of content dictionaries with url, type, duration, and position
|
List of content dictionaries with url, type, duration, and position
|
||||||
"""
|
"""
|
||||||
player = Player.query.get(player_id)
|
player = Player.query.get(player_id)
|
||||||
if not player:
|
if not player or not player.playlist_id:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Get content directly assigned to this player
|
# Get the player's assigned playlist
|
||||||
contents = Content.query.filter_by(player_id=player_id)\
|
playlist_obj = Playlist.query.get(player.playlist_id)
|
||||||
.order_by(Content.position, Content.uploaded_at)\
|
if not playlist_obj:
|
||||||
.all()
|
return []
|
||||||
|
|
||||||
|
# Get ordered content from the playlist
|
||||||
|
ordered_content = playlist_obj.get_content_ordered()
|
||||||
|
|
||||||
# Build playlist
|
# Build playlist
|
||||||
playlist = []
|
playlist = []
|
||||||
for content in contents:
|
for content in ordered_content:
|
||||||
playlist.append({
|
playlist.append({
|
||||||
'id': content.id,
|
'id': content.id,
|
||||||
'url': url_for('static', filename=f'uploads/{content.filename}'),
|
'url': url_for('static', filename=f'uploads/{content.filename}'),
|
||||||
'type': content.content_type,
|
'type': content.content_type,
|
||||||
'duration': content.duration or 10, # Default 10 seconds if not set
|
'duration': getattr(content, '_playlist_duration', content.duration or 10),
|
||||||
'position': content.position,
|
'position': getattr(content, '_playlist_position', 0),
|
||||||
'filename': content.filename
|
'filename': content.filename
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||||
flash, jsonify, current_app)
|
flash, jsonify, current_app)
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
from sqlalchemy import desc
|
from sqlalchemy import desc, update
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from app.extensions import db, cache
|
from app.extensions import db, cache
|
||||||
from app.models import Player, Content
|
from app.models import Player, Content, Playlist
|
||||||
|
from app.models.playlist import playlist_content
|
||||||
from app.utils.logger import log_action
|
from app.utils.logger import log_action
|
||||||
|
|
||||||
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
|
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
|
||||||
@@ -18,20 +19,22 @@ def manage_playlist(player_id: int):
|
|||||||
"""Manage playlist for a specific player."""
|
"""Manage playlist for a specific player."""
|
||||||
player = Player.query.get_or_404(player_id)
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
# Get all content for this player, ordered by position
|
# Get content from player's assigned playlist
|
||||||
playlist_content = Content.query.filter_by(
|
playlist_items = []
|
||||||
player_id=player_id
|
if player.playlist_id:
|
||||||
).order_by(Content.position).all()
|
playlist = Playlist.query.get(player.playlist_id)
|
||||||
|
if playlist:
|
||||||
|
playlist_items = playlist.get_content_ordered()
|
||||||
|
|
||||||
# Get available content (files not already in this player's playlist)
|
# Get available content (all content not in current playlist)
|
||||||
all_files = db.session.query(Content.filename).distinct().all()
|
all_content = Content.query.all()
|
||||||
playlist_filenames = {c.filename for c in playlist_content}
|
playlist_content_ids = {item.id for item in playlist_items}
|
||||||
available_files = [f[0] for f in all_files if f[0] not in playlist_filenames]
|
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
||||||
|
|
||||||
return render_template('playlist/manage_playlist.html',
|
return render_template('playlist/manage_playlist.html',
|
||||||
player=player,
|
player=player,
|
||||||
playlist_content=playlist_content,
|
playlist_content=playlist_items,
|
||||||
available_files=available_files)
|
available_content=available_content)
|
||||||
|
|
||||||
|
|
||||||
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
|
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
|
||||||
@@ -40,44 +43,46 @@ def add_to_playlist(player_id: int):
|
|||||||
"""Add content to player's playlist."""
|
"""Add content to player's playlist."""
|
||||||
player = Player.query.get_or_404(player_id)
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
|
if not player.playlist_id:
|
||||||
|
flash('Player has no playlist assigned.', 'warning')
|
||||||
|
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
filename = request.form.get('filename')
|
content_id = request.form.get('content_id', type=int)
|
||||||
duration = request.form.get('duration', type=int, default=10)
|
duration = request.form.get('duration', type=int, default=10)
|
||||||
|
|
||||||
if not filename:
|
if not content_id:
|
||||||
flash('Please provide a filename.', 'warning')
|
flash('Please select content.', 'warning')
|
||||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||||
|
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
playlist = Playlist.query.get(player.playlist_id)
|
||||||
|
|
||||||
# Get max position
|
# Get max position
|
||||||
max_position = db.session.query(db.func.max(Content.position)).filter_by(
|
from sqlalchemy import select, func
|
||||||
player_id=player_id
|
max_pos = db.session.execute(
|
||||||
|
select(func.max(playlist_content.c.position)).where(
|
||||||
|
playlist_content.c.playlist_id == playlist.id
|
||||||
|
)
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
|
|
||||||
# Get file info from existing content
|
# Add to playlist_content association table
|
||||||
existing_content = Content.query.filter_by(filename=filename).first()
|
stmt = playlist_content.insert().values(
|
||||||
if not existing_content:
|
playlist_id=playlist.id,
|
||||||
flash('File not found.', 'danger')
|
content_id=content.id,
|
||||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
position=max_pos + 1,
|
||||||
|
duration=duration
|
||||||
# Create new content entry for this player
|
|
||||||
new_content = Content(
|
|
||||||
filename=filename,
|
|
||||||
content_type=existing_content.content_type,
|
|
||||||
duration=duration,
|
|
||||||
file_size=existing_content.file_size,
|
|
||||||
player_id=player_id,
|
|
||||||
position=max_position + 1
|
|
||||||
)
|
)
|
||||||
db.session.add(new_content)
|
db.session.execute(stmt)
|
||||||
|
|
||||||
# Increment playlist version
|
# Increment playlist version
|
||||||
player.playlist_version += 1
|
playlist.increment_version()
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
cache.clear()
|
cache.clear()
|
||||||
|
|
||||||
log_action('info', f'Added "{filename}" to playlist for player "{player.name}"')
|
log_action('info', f'Added "{content.filename}" to playlist for player "{player.name}"')
|
||||||
flash(f'Added "{filename}" to playlist.', 'success')
|
flash(f'Added "{content.filename}" to playlist.', 'success')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
@@ -92,28 +97,41 @@ def add_to_playlist(player_id: int):
|
|||||||
def remove_from_playlist(player_id: int, content_id: int):
|
def remove_from_playlist(player_id: int, content_id: int):
|
||||||
"""Remove content from player's playlist."""
|
"""Remove content from player's playlist."""
|
||||||
player = Player.query.get_or_404(player_id)
|
player = Player.query.get_or_404(player_id)
|
||||||
content = Content.query.get_or_404(content_id)
|
|
||||||
|
|
||||||
if content.player_id != player_id:
|
if not player.playlist_id:
|
||||||
flash('Content does not belong to this player.', 'danger')
|
flash('Player has no playlist assigned.', 'danger')
|
||||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
playlist = Playlist.query.get(player.playlist_id)
|
||||||
filename = content.filename
|
filename = content.filename
|
||||||
|
|
||||||
# Delete content
|
# Remove from playlist_content association table
|
||||||
db.session.delete(content)
|
from sqlalchemy import delete
|
||||||
|
stmt = delete(playlist_content).where(
|
||||||
|
(playlist_content.c.playlist_id == playlist.id) &
|
||||||
|
(playlist_content.c.content_id == content_id)
|
||||||
|
)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
# Reorder remaining content
|
# Reorder remaining content
|
||||||
remaining_content = Content.query.filter_by(
|
from sqlalchemy import select
|
||||||
player_id=player_id
|
remaining = db.session.execute(
|
||||||
).order_by(Content.position).all()
|
select(playlist_content.c.content_id, playlist_content.c.position).where(
|
||||||
|
playlist_content.c.playlist_id == playlist.id
|
||||||
|
).order_by(playlist_content.c.position)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
for idx, item in enumerate(remaining_content, start=1):
|
for idx, row in enumerate(remaining, start=1):
|
||||||
item.position = idx
|
stmt = update(playlist_content).where(
|
||||||
|
(playlist_content.c.playlist_id == playlist.id) &
|
||||||
|
(playlist_content.c.content_id == row.content_id)
|
||||||
|
).values(position=idx)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
# Increment playlist version
|
# Increment playlist version
|
||||||
player.playlist_version += 1
|
playlist.increment_version()
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
cache.clear()
|
cache.clear()
|
||||||
@@ -135,7 +153,12 @@ def reorder_playlist(player_id: int):
|
|||||||
"""Reorder playlist items."""
|
"""Reorder playlist items."""
|
||||||
player = Player.query.get_or_404(player_id)
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
|
if not player.playlist_id:
|
||||||
|
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
playlist = Playlist.query.get(player.playlist_id)
|
||||||
|
|
||||||
# Get new order from JSON
|
# Get new order from JSON
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
content_ids = data.get('content_ids', [])
|
content_ids = data.get('content_ids', [])
|
||||||
@@ -143,24 +166,26 @@ def reorder_playlist(player_id: int):
|
|||||||
if not content_ids:
|
if not content_ids:
|
||||||
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
|
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
|
||||||
|
|
||||||
# Update positions
|
# Update positions in association table
|
||||||
for idx, content_id in enumerate(content_ids, start=1):
|
for idx, content_id in enumerate(content_ids, start=1):
|
||||||
content = Content.query.get(content_id)
|
stmt = update(playlist_content).where(
|
||||||
if content and content.player_id == player_id:
|
(playlist_content.c.playlist_id == playlist.id) &
|
||||||
content.position = idx
|
(playlist_content.c.content_id == content_id)
|
||||||
|
).values(position=idx)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
# Increment playlist version
|
# Increment playlist version
|
||||||
player.playlist_version += 1
|
playlist.increment_version()
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
cache.clear()
|
cache.clear()
|
||||||
|
|
||||||
log_action('info', f'Reordered playlist for player "{player.name}" (version {player.playlist_version})')
|
log_action('info', f'Reordered playlist for player "{player.name}" (version {playlist.version})')
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': 'Playlist reordered successfully',
|
'message': 'Playlist reordered successfully',
|
||||||
'version': player.playlist_version
|
'version': playlist.version
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -174,19 +199,28 @@ def reorder_playlist(player_id: int):
|
|||||||
def update_duration(player_id: int, content_id: int):
|
def update_duration(player_id: int, content_id: int):
|
||||||
"""Update content duration in playlist."""
|
"""Update content duration in playlist."""
|
||||||
player = Player.query.get_or_404(player_id)
|
player = Player.query.get_or_404(player_id)
|
||||||
content = Content.query.get_or_404(content_id)
|
|
||||||
|
|
||||||
if content.player_id != player_id:
|
if not player.playlist_id:
|
||||||
return jsonify({'success': False, 'message': 'Content does not belong to this player'}), 403
|
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
playlist = Playlist.query.get(player.playlist_id)
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
|
||||||
duration = request.form.get('duration', type=int)
|
duration = request.form.get('duration', type=int)
|
||||||
|
|
||||||
if not duration or duration < 1:
|
if not duration or duration < 1:
|
||||||
return jsonify({'success': False, 'message': 'Invalid duration'}), 400
|
return jsonify({'success': False, 'message': 'Invalid duration'}), 400
|
||||||
|
|
||||||
content.duration = duration
|
# Update duration in association table
|
||||||
player.playlist_version += 1
|
stmt = update(playlist_content).where(
|
||||||
|
(playlist_content.c.playlist_id == playlist.id) &
|
||||||
|
(playlist_content.c.content_id == content_id)
|
||||||
|
).values(duration=duration)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
# Increment playlist version
|
||||||
|
playlist.increment_version()
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
cache.clear()
|
cache.clear()
|
||||||
@@ -196,7 +230,7 @@ def update_duration(player_id: int, content_id: int):
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': 'Duration updated',
|
'message': 'Duration updated',
|
||||||
'version': player.playlist_version
|
'version': playlist.version
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -211,12 +245,22 @@ def clear_playlist(player_id: int):
|
|||||||
"""Clear all content from player's playlist."""
|
"""Clear all content from player's playlist."""
|
||||||
player = Player.query.get_or_404(player_id)
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
|
if not player.playlist_id:
|
||||||
|
flash('Player has no playlist assigned.', 'warning')
|
||||||
|
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Delete all content for this player
|
playlist = Playlist.query.get(player.playlist_id)
|
||||||
Content.query.filter_by(player_id=player_id).delete()
|
|
||||||
|
# Delete all content from playlist
|
||||||
|
from sqlalchemy import delete
|
||||||
|
stmt = delete(playlist_content).where(
|
||||||
|
playlist_content.c.playlist_id == playlist.id
|
||||||
|
)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
# Increment playlist version
|
# Increment playlist version
|
||||||
player.playlist_version += 1
|
playlist.increment_version()
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
cache.clear()
|
cache.clear()
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ class Config:
|
|||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
SQLALCHEMY_ECHO = False
|
SQLALCHEMY_ECHO = False
|
||||||
|
|
||||||
# File Upload
|
# File Upload - use absolute paths
|
||||||
MAX_CONTENT_LENGTH = 2048 * 1024 * 1024 # 2GB
|
MAX_CONTENT_LENGTH = 2048 * 1024 * 1024 # 2GB
|
||||||
UPLOAD_FOLDER = 'static/uploads'
|
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
UPLOAD_FOLDERLOGO = 'static/resurse'
|
UPLOAD_FOLDER = os.path.join(_basedir, 'static', 'uploads')
|
||||||
|
UPLOAD_FOLDERLOGO = os.path.join(_basedir, 'static', 'resurse')
|
||||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'mp4', 'avi', 'mkv', 'mov', 'webm', 'pdf', 'ppt', 'pptx'}
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'mp4', 'avi', 'mkv', 'mov', 'webm', 'pdf', 'ppt', 'pptx'}
|
||||||
|
|
||||||
# Session
|
# Session
|
||||||
|
|||||||
@@ -11,24 +11,39 @@
|
|||||||
<h2>📊 System Overview</h2>
|
<h2>📊 System Overview</h2>
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-label">Total Users:</span>
|
<div class="stat-icon">👥</div>
|
||||||
<span class="stat-value">{{ total_users or 0 }}</span>
|
<div class="stat-content">
|
||||||
|
<span class="stat-label">Total Users</span>
|
||||||
|
<span class="stat-value">{{ total_users or 0 }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-label">Total Players:</span>
|
<div class="stat-icon">🖥️</div>
|
||||||
<span class="stat-value">{{ total_players or 0 }}</span>
|
<div class="stat-content">
|
||||||
|
<span class="stat-label">Total Players</span>
|
||||||
|
<span class="stat-value">{{ total_players or 0 }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-label">Total Groups:</span>
|
<div class="stat-icon">📋</div>
|
||||||
<span class="stat-value">{{ total_groups or 0 }}</span>
|
<div class="stat-content">
|
||||||
|
<span class="stat-label">Total Playlists</span>
|
||||||
|
<span class="stat-value">{{ total_playlists or 0 }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-label">Total Content:</span>
|
<div class="stat-icon">📁</div>
|
||||||
<span class="stat-value">{{ total_content or 0 }}</span>
|
<div class="stat-content">
|
||||||
|
<span class="stat-label">Media Files</span>
|
||||||
|
<span class="stat-value">{{ total_content or 0 }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-label">Storage Used:</span>
|
<div class="stat-icon">💾</div>
|
||||||
<span class="stat-value">{{ storage_mb or 0 }} MB</span>
|
<div class="stat-content">
|
||||||
|
<span class="stat-label">Storage Used</span>
|
||||||
|
<span class="stat-value">{{ storage_mb or 0 }} MB</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,13 +59,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Leftover Media Management Card -->
|
||||||
|
<div class="card management-card">
|
||||||
|
<h2>🗑️ Manage Leftover Media</h2>
|
||||||
|
<p>Clean up media files not assigned to any playlist</p>
|
||||||
|
<div class="card-actions">
|
||||||
|
<a href="{{ url_for('admin.leftover_media') }}" class="btn btn-warning">
|
||||||
|
Manage Leftover Files
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions Card -->
|
<!-- Quick Actions Card -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>⚡ Quick Actions</h2>
|
<h2>⚡ Quick Actions</h2>
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">View Players</a>
|
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">🖥️ View Players</a>
|
||||||
<a href="{{ url_for('groups.groups_list') }}" class="btn btn-secondary">View Groups</a>
|
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📋 View Playlists</a>
|
||||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">View Content</a>
|
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📁 View Media Library</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,22 +97,61 @@
|
|||||||
|
|
||||||
.stat-item {
|
.stat-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
padding: 10px;
|
gap: 12px;
|
||||||
|
padding: 15px;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .stat-item {
|
||||||
|
background: #1a202c;
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .stat-item:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.dark-mode .stat-label {
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
font-size: 1.5rem;
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.dark-mode .stat-value {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
.management-card {
|
.management-card {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
203
app/templates/admin/leftover_media.html
Normal file
203
app/templates/admin/leftover_media.html
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Leftover Media - Admin - DigiServer v2{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width: 1400px; margin: 0 auto;">
|
||||||
|
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h1 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
🗑️ Manage Leftover Media
|
||||||
|
</h1>
|
||||||
|
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
|
||||||
|
← Back to Admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overview Stats -->
|
||||||
|
<div class="card" style="margin-bottom: 30px;">
|
||||||
|
<h2>📊 Overview</h2>
|
||||||
|
<p style="color: #6c757d; margin-bottom: 20px;">
|
||||||
|
Media files that are not assigned to any playlist. These can be safely deleted to free up storage.
|
||||||
|
</p>
|
||||||
|
<div class="stats-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
|
||||||
|
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||||
|
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Leftover Files</div>
|
||||||
|
<div style="font-size: 24px; font-weight: bold;">{{ total_leftover }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||||
|
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Size</div>
|
||||||
|
<div style="font-size: 24px; font-weight: bold;">{{ "%.2f"|format(total_leftover_size_mb) }} MB</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||||
|
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Images</div>
|
||||||
|
<div style="font-size: 24px; font-weight: bold;">{{ leftover_images|length }} ({{ "%.2f"|format(images_size_mb) }} MB)</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
|
||||||
|
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Videos</div>
|
||||||
|
<div style="font-size: 24px; font-weight: bold;">{{ leftover_videos|length }} ({{ "%.2f"|format(videos_size_mb) }} MB)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Images Section -->
|
||||||
|
<div class="card" style="margin-bottom: 30px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
|
<h2>📷 Leftover Images ({{ leftover_images|length }})</h2>
|
||||||
|
{% if leftover_images %}
|
||||||
|
<form method="POST" action="{{ url_for('admin.delete_leftover_images') }}"
|
||||||
|
onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_images|length }} leftover images? This cannot be undone!');"
|
||||||
|
style="display: inline;">
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
🗑️ Delete All Images
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if leftover_images %}
|
||||||
|
<div style="max-height: 400px; overflow-y: auto;">
|
||||||
|
<table class="table" style="width: 100%; border-collapse: collapse;">
|
||||||
|
<thead style="position: sticky; top: 0; background: #f8f9fa;">
|
||||||
|
<tr>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for img in leftover_images %}
|
||||||
|
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||||
|
<td style="padding: 10px;">📷 {{ img.filename }}</td>
|
||||||
|
<td style="padding: 10px;">{{ "%.2f"|format(img.file_size_mb) }} MB</td>
|
||||||
|
<td style="padding: 10px;">{{ img.duration }}s</td>
|
||||||
|
<td style="padding: 10px;">{{ img.uploaded_at.strftime('%Y-%m-%d %H:%M') if img.uploaded_at else 'N/A' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; padding: 40px; color: #999;">
|
||||||
|
<div style="font-size: 48px; margin-bottom: 15px;">✓</div>
|
||||||
|
<p>No leftover images found. All images are assigned to playlists!</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Videos Section -->
|
||||||
|
<div class="card" style="margin-bottom: 30px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||||
|
<h2 style="margin: 0;">🎥 Leftover Videos ({{ leftover_videos|length }})</h2>
|
||||||
|
{% if leftover_videos %}
|
||||||
|
<form method="POST" action="{{ url_for('admin.delete_leftover_videos') }}" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_videos|length }} leftover videos? This action cannot be undone!');">
|
||||||
|
<button type="submit" class="btn btn-danger" style="padding: 8px 16px; font-size: 14px;">
|
||||||
|
🗑️ Delete All Videos
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if leftover_videos %}
|
||||||
|
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
|
||||||
|
<table class="table" style="width: 100%; border-collapse: collapse;">
|
||||||
|
<thead style="position: sticky; top: 0; background: #f8f9fa;">
|
||||||
|
<tr>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for video in leftover_videos %}
|
||||||
|
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||||
|
<td style="padding: 10px;">🎥 {{ video.filename }}</td>
|
||||||
|
<td style="padding: 10px;">{{ "%.2f"|format(video.file_size_mb) }} MB</td>
|
||||||
|
<td style="padding: 10px;">{{ video.duration }}s</td>
|
||||||
|
<td style="padding: 10px;">{{ video.uploaded_at.strftime('%Y-%m-%d %H:%M') if video.uploaded_at else 'N/A' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; padding: 40px; color: #999;">
|
||||||
|
<div style="font-size: 48px; margin-bottom: 15px;">✓</div>
|
||||||
|
<p>No leftover videos found. All videos are assigned to playlists!</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PDFs Section -->
|
||||||
|
<div class="card" style="margin-bottom: 30px;">
|
||||||
|
<h2>📄 Leftover PDFs ({{ leftover_pdfs|length }})</h2>
|
||||||
|
|
||||||
|
{% if leftover_pdfs %}
|
||||||
|
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
|
||||||
|
<table class="table" style="width: 100%; border-collapse: collapse;">
|
||||||
|
<thead style="position: sticky; top: 0; background: #f8f9fa;">
|
||||||
|
<tr>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||||
|
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for pdf in leftover_pdfs %}
|
||||||
|
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||||
|
<td style="padding: 10px;">📄 {{ pdf.filename }}</td>
|
||||||
|
<td style="padding: 10px;">{{ "%.2f"|format(pdf.file_size_mb) }} MB</td>
|
||||||
|
<td style="padding: 10px;">{{ pdf.duration }}s</td>
|
||||||
|
<td style="padding: 10px;">{{ pdf.uploaded_at.strftime('%Y-%m-%d %H:%M') if pdf.uploaded_at else 'N/A' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; padding: 40px; color: #999;">
|
||||||
|
<div style="font-size: 48px; margin-bottom: 15px;">✓</div>
|
||||||
|
<p>No leftover PDFs found. All PDFs are assigned to playlists!</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body.dark-mode .card {
|
||||||
|
background: #2d3748;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode h1,
|
||||||
|
body.dark-mode h2 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .stat-item {
|
||||||
|
background: #1a202c !important;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table thead {
|
||||||
|
background: #1a202c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table th,
|
||||||
|
body.dark-mode .table td {
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-color: #4a5568 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table tr {
|
||||||
|
border-color: #4a5568 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .table tr:hover {
|
||||||
|
background: #1a202c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -444,10 +444,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 12px;">
|
<td style="padding: 12px;">
|
||||||
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
class="btn btn-primary btn-sm">
|
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
|
||||||
👁️ View
|
class="btn btn-primary btn-sm">
|
||||||
</a>
|
⚙️ Manage
|
||||||
|
</a>
|
||||||
|
{% if player.playlist_id %}
|
||||||
|
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
|
||||||
|
class="btn btn-success btn-sm" target="_blank"
|
||||||
|
title="View live content preview">
|
||||||
|
🖥️ Live
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -5,14 +5,15 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
<style>
|
||||||
.upload-container {
|
.upload-container {
|
||||||
max-width: 900px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-zone {
|
.upload-zone {
|
||||||
border: 3px dashed #ced4da;
|
border: 2px dashed #ced4da;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
padding: 60px 40px;
|
padding: 25px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
@@ -54,18 +55,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-list {
|
.file-list {
|
||||||
margin-top: 20px;
|
margin-top: 15px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-item {
|
.file-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px;
|
padding: 8px 10px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .file-item {
|
body.dark-mode .file-item {
|
||||||
@@ -80,7 +84,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-icon {
|
.file-icon {
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-file {
|
.remove-file {
|
||||||
@@ -97,7 +101,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
@@ -205,106 +209,100 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="upload-container">
|
<div class="upload-container">
|
||||||
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center;">
|
<div style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
<h1 style="display: flex; align-items: center; gap: 0.5rem;">
|
<h1 style="display: flex; align-items: center; gap: 0.5rem; font-size: 24px; margin: 0;">
|
||||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 32px; height: 32px;">
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 28px; height: 28px;">
|
||||||
Upload Media Files
|
Upload Media
|
||||||
</h1>
|
</h1>
|
||||||
<a href="{{ url_for('content.content_list') }}" class="btn" style="display: flex; align-items: center; gap: 0.5rem;">
|
<a href="{{ url_for('content.content_list') }}" class="btn" style="display: flex; align-items: center; gap: 0.5rem; padding: 8px 16px;">
|
||||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
|
||||||
Back to Playlists
|
Back to Playlists
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="upload-form" method="POST" action="{{ url_for('content.upload_media') }}" enctype="multipart/form-data">
|
<form id="upload-form" method="POST" action="{{ url_for('content.upload_media') }}" enctype="multipart/form-data">
|
||||||
|
|
||||||
<!-- Playlist Selector -->
|
<!-- Compact Two-Column Layout -->
|
||||||
<div class="card" style="margin-bottom: 30px;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||||
<h2 style="margin-bottom: 15px;">📋 Select Target Playlist (Optional)</h2>
|
|
||||||
<p style="color: #6c757d; margin-bottom: 20px;">
|
|
||||||
Choose a playlist to directly add uploaded files, or leave blank to add to media library only.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<!-- Left Column: Upload Zone -->
|
||||||
<label for="playlist_id">Target Playlist</label>
|
<div class="card">
|
||||||
<select name="playlist_id" id="playlist_id" class="form-control">
|
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<option value="">-- Media Library Only (Don't add to playlist) --</option>
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px;">
|
||||||
{% for playlist in playlists %}
|
Select Files
|
||||||
<option value="{{ playlist.id }}">
|
</h2>
|
||||||
{{ playlist.name }} ({{ playlist.orientation }}) - v{{ playlist.version }} - {{ playlist.content_count }} items
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<small style="color: #6c757d; display: block; margin-top: 5px;">
|
|
||||||
💡 Tip: You can add files to playlists later from the media library
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload Zone -->
|
<div class="upload-zone" id="upload-zone">
|
||||||
<div class="card" style="margin-bottom: 30px;">
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 48px; height: 48px; opacity: 0.3; margin-bottom: 10px;">
|
||||||
<h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 0.5rem;">
|
<h3 style="margin-bottom: 8px; font-size: 16px;">Drag & Drop</h3>
|
||||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px;">
|
<p style="color: #6c757d; margin: 8px 0; font-size: 13px;">or</p>
|
||||||
Select Files
|
<div class="file-input-wrapper">
|
||||||
</h2>
|
<label for="file-input" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem; padding: 8px 16px; font-size: 14px;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
|
||||||
<div class="upload-zone" id="upload-zone">
|
Browse
|
||||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 96px; height: 96px; opacity: 0.3; margin-bottom: 20px;">
|
</label>
|
||||||
<h3 style="margin-bottom: 10px;">Drag and Drop Files Here</h3>
|
<input type="file" id="file-input" name="files" multiple
|
||||||
<p style="color: #6c757d; margin: 15px 0;">or</p>
|
accept="image/*,video/*,.pdf,.ppt,.pptx">
|
||||||
<div class="file-input-wrapper">
|
</div>
|
||||||
<label for="file-input" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
<p style="font-size: 11px; color: #999; margin-top: 12px; line-height: 1.4;">
|
||||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
<strong>Supported:</strong> JPG, PNG, GIF, MP4, AVI, MOV, PDF, PPT
|
||||||
Browse Files
|
</p>
|
||||||
</label>
|
|
||||||
<input type="file" id="file-input" name="files" multiple
|
|
||||||
accept="image/*,video/*,.pdf,.ppt,.pptx">
|
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size: 14px; color: #999; margin-top: 20px;">
|
|
||||||
<strong>Supported formats:</strong><br>
|
<div id="file-list" class="file-list"></div>
|
||||||
Images: JPG, PNG, GIF, BMP<br>
|
|
||||||
Videos: MP4, AVI, MOV, MKV, WEBM<br>
|
|
||||||
Documents: PDF, PPT, PPTX
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="file-list" class="file-list"></div>
|
<!-- Right Column: Settings -->
|
||||||
</div>
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px;">
|
||||||
|
Upload Settings
|
||||||
|
</h2>
|
||||||
|
|
||||||
<!-- Upload Settings -->
|
<div class="form-group">
|
||||||
<div class="card" style="margin-bottom: 30px;">
|
<label for="playlist_id">Target Playlist (Optional)</label>
|
||||||
<h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 0.5rem;">
|
<select name="playlist_id" id="playlist_id" class="form-control">
|
||||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
<option value="">-- Media Library Only --</option>
|
||||||
Upload Settings
|
{% for playlist in playlists %}
|
||||||
</h2>
|
<option value="{{ playlist.id }}">
|
||||||
|
{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
|
||||||
|
💡 Add to playlists later if needed
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="content_type">Media Type</label>
|
<label for="content_type">Media Type</label>
|
||||||
<select name="content_type" id="content_type" class="form-control">
|
<select name="content_type" id="content_type" class="form-control">
|
||||||
<option value="image">Image</option>
|
<option value="image">Image</option>
|
||||||
<option value="video">Video</option>
|
<option value="video">Video</option>
|
||||||
<option value="pdf">PDF Document</option>
|
<option value="pdf">PDF</option>
|
||||||
</select>
|
<option value="pptx">PPTX</option>
|
||||||
<small style="color: #6c757d; display: block; margin-top: 5px;">
|
</select>
|
||||||
This will be auto-detected from file extension
|
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
|
||||||
</small>
|
Auto-detected from file extension
|
||||||
</div>
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="duration">Default Duration (seconds)</label>
|
<label for="duration">Default Duration (seconds)</label>
|
||||||
<input type="number" name="duration" id="duration" class="form-control"
|
<input type="number" name="duration" id="duration" class="form-control"
|
||||||
value="10" min="1" max="300">
|
value="10" min="1" max="300">
|
||||||
<small style="color: #6c757d; display: block; margin-top: 5px;">
|
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
|
||||||
How long each item should display (for images and PDFs)
|
Display time for images and PDFs
|
||||||
</small>
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Button -->
|
||||||
|
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||||
|
Upload Files
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload Button -->
|
|
||||||
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
|
||||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px; filter: brightness(0) invert(1);">
|
|
||||||
Upload Files
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -350,18 +348,97 @@
|
|||||||
|
|
||||||
// Handle file input
|
// Handle file input
|
||||||
fileInput.addEventListener('change', (e) => {
|
fileInput.addEventListener('change', (e) => {
|
||||||
handleFiles(e.target.files);
|
console.log('File input changed, files:', e.target.files.length);
|
||||||
|
if (e.target.files.length > 0) {
|
||||||
|
handleFiles(e.target.files);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click upload zone to trigger file input
|
// Click upload zone to trigger file input (but not if clicking on the label)
|
||||||
uploadZone.addEventListener('click', () => {
|
uploadZone.addEventListener('click', (e) => {
|
||||||
|
// Don't trigger if clicking on the label or button
|
||||||
|
if (e.target.tagName === 'LABEL' || e.target.closest('label') || e.target.tagName === 'INPUT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
fileInput.click();
|
fileInput.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleFiles(files) {
|
function handleFiles(files) {
|
||||||
|
console.log('handleFiles called with', files.length, 'file(s)');
|
||||||
selectedFiles = Array.from(files);
|
selectedFiles = Array.from(files);
|
||||||
displayFiles();
|
displayFiles();
|
||||||
uploadBtn.disabled = selectedFiles.length === 0;
|
uploadBtn.disabled = selectedFiles.length === 0;
|
||||||
|
|
||||||
|
// Auto-detect media type and duration from first file
|
||||||
|
if (selectedFiles.length > 0) {
|
||||||
|
console.log('Auto-detecting for first file:', selectedFiles[0].name);
|
||||||
|
autoDetectMediaType(selectedFiles[0]);
|
||||||
|
autoDetectDuration(selectedFiles[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoDetectMediaType(file) {
|
||||||
|
const ext = file.name.split('.').pop().toLowerCase();
|
||||||
|
const contentTypeSelect = document.getElementById('content_type');
|
||||||
|
|
||||||
|
console.log('Auto-detecting media type for:', file.name, 'Extension:', ext);
|
||||||
|
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
|
||||||
|
contentTypeSelect.value = 'image';
|
||||||
|
console.log('Set type to: image');
|
||||||
|
} else if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
|
||||||
|
contentTypeSelect.value = 'video';
|
||||||
|
console.log('Set type to: video');
|
||||||
|
} else if (ext === 'pdf') {
|
||||||
|
contentTypeSelect.value = 'pdf';
|
||||||
|
console.log('Set type to: pdf');
|
||||||
|
} else if (['ppt', 'pptx'].includes(ext)) {
|
||||||
|
contentTypeSelect.value = 'pptx';
|
||||||
|
console.log('Set type to: pptx');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoDetectDuration(file) {
|
||||||
|
const ext = file.name.split('.').pop().toLowerCase();
|
||||||
|
const durationInput = document.getElementById('duration');
|
||||||
|
|
||||||
|
console.log('Auto-detecting duration for:', file.name, 'Extension:', ext);
|
||||||
|
|
||||||
|
// For videos, try to get actual duration
|
||||||
|
if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
|
||||||
|
console.log('Processing as video...');
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.preload = 'metadata';
|
||||||
|
|
||||||
|
video.onloadedmetadata = function() {
|
||||||
|
window.URL.revokeObjectURL(video.src);
|
||||||
|
const duration = Math.ceil(video.duration);
|
||||||
|
console.log('Video duration detected:', duration, 'seconds');
|
||||||
|
if (duration && duration > 0) {
|
||||||
|
durationInput.value = duration;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
video.onerror = function() {
|
||||||
|
console.log('Video loading error, using default 30s');
|
||||||
|
window.URL.revokeObjectURL(video.src);
|
||||||
|
durationInput.value = 30; // Default for videos if can't read duration
|
||||||
|
};
|
||||||
|
|
||||||
|
video.src = URL.createObjectURL(file);
|
||||||
|
} else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
|
||||||
|
// Images: default 10 seconds
|
||||||
|
console.log('Setting image duration: 10s');
|
||||||
|
durationInput.value = 10;
|
||||||
|
} else if (ext === 'pdf') {
|
||||||
|
// PDFs: default 15 seconds per page (estimate)
|
||||||
|
console.log('Setting PDF duration: 15s');
|
||||||
|
durationInput.value = 15;
|
||||||
|
} else if (['ppt', 'pptx'].includes(ext)) {
|
||||||
|
// Presentations: default 20 seconds per slide (estimate)
|
||||||
|
console.log('Setting presentation duration: 20s');
|
||||||
|
durationInput.value = 20;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayFiles() {
|
function displayFiles() {
|
||||||
|
|||||||
@@ -3,6 +3,215 @@
|
|||||||
{% block title %}Manage Player - {{ player.name }}{% endblock %}
|
{% block title %}Manage Player - {{ player.name }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .form-group label {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .form-control {
|
||||||
|
background: #1a202c;
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .form-control:focus {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box.neutral {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .info-box.neutral {
|
||||||
|
background: #1a202c;
|
||||||
|
border: 1px solid #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .info-box.success {
|
||||||
|
background: #1a4d2e;
|
||||||
|
color: #86efac;
|
||||||
|
border: 1px solid #48bb78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box.warning {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .info-box.warning {
|
||||||
|
background: #4a3800;
|
||||||
|
color: #fbbf24;
|
||||||
|
border: 1px solid #ecc94b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-left: 4px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .log-item {
|
||||||
|
background: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item pre {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .log-item pre {
|
||||||
|
background: #1a202c;
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode h1,
|
||||||
|
body.dark-mode h2,
|
||||||
|
body.dark-mode h3,
|
||||||
|
body.dark-mode h4 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode p {
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode strong {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode code {
|
||||||
|
background: #1a202c;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode small {
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-item {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .credential-item {
|
||||||
|
border-bottom-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-label {
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .credential-label {
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-value {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .credential-value {
|
||||||
|
background: #0d1117;
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card.online {
|
||||||
|
background: #d4edda;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .status-card.online {
|
||||||
|
background: #1a4d2e;
|
||||||
|
border: 1px solid #48bb78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card.offline {
|
||||||
|
background: #f8d7da;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .status-card.offline {
|
||||||
|
background: #4a1a1a;
|
||||||
|
border: 1px solid #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card.other {
|
||||||
|
background: #fff3cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .status-card.other {
|
||||||
|
background: #4a3800;
|
||||||
|
border: 1px solid #ecc94b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-stats {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .playlist-stats {
|
||||||
|
background: #1a202c;
|
||||||
|
border: 1px solid #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .playlist-stats > div > div:first-child {
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<div style="margin-bottom: 2rem;">
|
<div style="margin-bottom: 2rem;">
|
||||||
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;">
|
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
|
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||||
@@ -15,7 +224,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Player Status Overview -->
|
<!-- Player Status Overview -->
|
||||||
<div class="card" style="margin-bottom: 2rem; background: {% if player.status == 'online' %}#d4edda{% elif player.status == 'offline' %}#f8d7da{% else %}#fff3cd{% endif %};">
|
<div class="card status-card {% if player.status == 'online' %}online{% elif player.status == 'offline' %}offline{% else %}other{% endif %}">
|
||||||
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
|
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
Status:
|
Status:
|
||||||
{% if player.status == 'online' %}
|
{% if player.status == 'online' %}
|
||||||
@@ -52,6 +261,47 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Playlist Overview Card -->
|
||||||
|
{% if current_playlist %}
|
||||||
|
<div class="card" style="margin-bottom: 2rem;">
|
||||||
|
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||||
|
Current Playlist: {{ current_playlist.name }}
|
||||||
|
</h2>
|
||||||
|
<div class="playlist-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-top: 1rem;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Total Items</div>
|
||||||
|
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.contents.count() }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Playlist Version</div>
|
||||||
|
<div style="font-size: 1.5rem; font-weight: bold;">v{{ current_playlist.version }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Last Updated</div>
|
||||||
|
<div style="font-size: 1rem; font-weight: bold;">{{ current_playlist.updated_at.strftime('%Y-%m-%d %H:%M') }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Orientation</div>
|
||||||
|
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.orientation }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 1rem; display: flex; gap: 0.5rem;">
|
||||||
|
<a href="{{ url_for('content.manage_playlist_content', playlist_id=current_playlist.id) }}"
|
||||||
|
class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||||
|
Edit Playlist Content
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
|
||||||
|
class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;"
|
||||||
|
target="_blank">
|
||||||
|
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||||
|
View Live Content
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Three Column Layout -->
|
<!-- Three Column Layout -->
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1.5rem;">
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1.5rem;">
|
||||||
|
|
||||||
@@ -64,33 +314,44 @@
|
|||||||
<form method="POST" style="margin-top: 1rem;">
|
<form method="POST" style="margin-top: 1rem;">
|
||||||
<input type="hidden" name="action" value="update_credentials">
|
<input type="hidden" name="action" value="update_credentials">
|
||||||
|
|
||||||
<div style="margin-bottom: 1rem;">
|
<div class="form-group">
|
||||||
<label for="name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Player Name *</label>
|
<label for="name">Player Name *</label>
|
||||||
<input type="text" id="name" name="name" value="{{ player.name }}"
|
<input type="text" id="name" name="name" value="{{ player.name }}"
|
||||||
required minlength="3"
|
required minlength="3" class="form-control">
|
||||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom: 1rem;">
|
<div class="form-group">
|
||||||
<label for="location" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Location</label>
|
<label for="location">Location</label>
|
||||||
<input type="text" id="location" name="location" value="{{ player.location or '' }}"
|
<input type="text" id="location" name="location" value="{{ player.location or '' }}"
|
||||||
placeholder="e.g., Main Lobby"
|
placeholder="e.g., Main Lobby" class="form-control">
|
||||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom: 1rem;">
|
<div class="form-group">
|
||||||
<label for="orientation" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Orientation</label>
|
<label for="orientation">Orientation</label>
|
||||||
<select id="orientation" name="orientation"
|
<select id="orientation" name="orientation" class="form-control">
|
||||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
|
||||||
<option value="Landscape" {% if player.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
|
<option value="Landscape" {% if player.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
|
||||||
<option value="Portrait" {% if player.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
|
<option value="Portrait" {% if player.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="padding: 1rem; background: #f8f9fa; border-radius: 4px; margin-bottom: 1rem;">
|
<div class="info-box neutral">
|
||||||
<p style="margin: 0; font-size: 0.9rem;"><strong>Hostname:</strong> {{ player.hostname }}</p>
|
<h4 style="margin: 0 0 1rem 0; font-size: 0.95rem; color: #495057;">🔑 Player Credentials</h4>
|
||||||
<p style="margin: 0.5rem 0 0 0; font-size: 0.9rem;"><strong>Auth Code:</strong> <code>{{ player.auth_code }}</code></p>
|
|
||||||
<p style="margin: 0.5rem 0 0 0; font-size: 0.9rem;"><strong>Quick Connect:</strong> {{ player.quickconnect_code or 'Not set' }}</p>
|
<div class="credential-item">
|
||||||
|
<span class="credential-label">Hostname</span>
|
||||||
|
<div class="credential-value">{{ player.hostname }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="credential-item">
|
||||||
|
<span class="credential-label">Auth Code</span>
|
||||||
|
<div class="credential-value">{{ player.auth_code }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="credential-item">
|
||||||
|
<span class="credential-label">Quick Connect Code (Hashed)</span>
|
||||||
|
<div class="credential-value" style="font-size: 0.75rem;">{{ player.quickconnect_code or 'Not set' }}</div>
|
||||||
|
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">⚠️ This is the hashed version for security</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||||
@@ -109,10 +370,9 @@
|
|||||||
<form method="POST" style="margin-top: 1rem;">
|
<form method="POST" style="margin-top: 1rem;">
|
||||||
<input type="hidden" name="action" value="assign_playlist">
|
<input type="hidden" name="action" value="assign_playlist">
|
||||||
|
|
||||||
<div style="margin-bottom: 1rem;">
|
<div class="form-group">
|
||||||
<label for="playlist_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Select Playlist</label>
|
<label for="playlist_id">Select Playlist</label>
|
||||||
<select id="playlist_id" name="playlist_id"
|
<select id="playlist_id" name="playlist_id" class="form-control">
|
||||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
|
||||||
<option value="">-- No Playlist (Unassign) --</option>
|
<option value="">-- No Playlist (Unassign) --</option>
|
||||||
{% for playlist in playlists %}
|
{% for playlist in playlists %}
|
||||||
<option value="{{ playlist.id }}"
|
<option value="{{ playlist.id }}"
|
||||||
@@ -124,8 +384,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if current_playlist %}
|
{% if current_playlist %}
|
||||||
<div style="padding: 1rem; background: #d4edda; border-radius: 4px; margin-bottom: 1rem;">
|
<div class="info-box success">
|
||||||
<h4 style="margin: 0 0 0.5rem 0; color: #155724;">Currently Assigned:</h4>
|
<h4 style="margin: 0 0 0.5rem 0;">Currently Assigned:</h4>
|
||||||
<p style="margin: 0;"><strong>{{ current_playlist.name }}</strong></p>
|
<p style="margin: 0;"><strong>{{ current_playlist.name }}</strong></p>
|
||||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Version: {{ current_playlist.version }}</p>
|
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Version: {{ current_playlist.version }}</p>
|
||||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Content Items: {{ current_playlist.contents.count() }}</p>
|
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Content Items: {{ current_playlist.contents.count() }}</p>
|
||||||
@@ -134,8 +394,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="padding: 1rem; background: #fff3cd; border-radius: 4px; margin-bottom: 1rem;">
|
<div class="info-box warning">
|
||||||
<p style="margin: 0; color: #856404; display: flex; align-items: center; gap: 0.5rem;">
|
<p style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 18px; height: 18px;">
|
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 18px; height: 18px;">
|
||||||
No playlist currently assigned to this player.
|
No playlist currently assigned to this player.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,10 +1,234 @@
|
|||||||
{% extends "base.html" %}
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ player.name }} - Live Preview</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
{% block title %}Player Fullscreen{% endblock %}
|
body {
|
||||||
|
background: #000;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
{% block content %}
|
#player-container {
|
||||||
<div class="container">
|
width: 100vw;
|
||||||
<h2>Player Fullscreen View</h2>
|
height: 100vh;
|
||||||
<p>Fullscreen player view - placeholder</p>
|
display: flex;
|
||||||
</div>
|
align-items: center;
|
||||||
{% endblock %}
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#media-display {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#media-display.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-overlay {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 1000;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-content {
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="player-container">
|
||||||
|
<div class="loading" id="loading">Loading playlist...</div>
|
||||||
|
<img id="media-display" alt="Content">
|
||||||
|
<video id="video-display" style="display: none; max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain;"></video>
|
||||||
|
<div class="no-content" id="no-content" style="display: none;">
|
||||||
|
<p>💭 No content in playlist</p>
|
||||||
|
<p style="font-size: 16px; margin-top: 10px; opacity: 0.7;">Add content to the playlist to preview</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-overlay" id="info-overlay" style="display: none;">
|
||||||
|
<div id="current-item">Item: -</div>
|
||||||
|
<div id="playlist-info">Playlist: {{ playlist|length }} items</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button onclick="toggleFullscreen()">🔳 Fullscreen</button>
|
||||||
|
<button onclick="restartPlaylist()">🔄 Restart</button>
|
||||||
|
<button onclick="window.close()">✖️ Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const playlist = {{ playlist|tojson }};
|
||||||
|
let currentIndex = 0;
|
||||||
|
let timer = null;
|
||||||
|
let inactivityTimer = null;
|
||||||
|
|
||||||
|
const imgDisplay = document.getElementById('media-display');
|
||||||
|
const videoDisplay = document.getElementById('video-display');
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
const noContent = document.getElementById('no-content');
|
||||||
|
const infoOverlay = document.getElementById('info-overlay');
|
||||||
|
const currentItemDiv = document.getElementById('current-item');
|
||||||
|
const controls = document.querySelector('.controls');
|
||||||
|
|
||||||
|
function playNext() {
|
||||||
|
if (!playlist || playlist.length === 0) {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
noContent.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = playlist[currentIndex];
|
||||||
|
loading.style.display = 'none';
|
||||||
|
infoOverlay.style.display = 'block';
|
||||||
|
|
||||||
|
// Update info
|
||||||
|
currentItemDiv.textContent = `Item ${currentIndex + 1}/${playlist.length}: ${item.filename}`;
|
||||||
|
|
||||||
|
// Hide both displays
|
||||||
|
imgDisplay.style.display = 'none';
|
||||||
|
videoDisplay.style.display = 'none';
|
||||||
|
videoDisplay.pause();
|
||||||
|
|
||||||
|
if (item.type === 'video') {
|
||||||
|
videoDisplay.src = item.url;
|
||||||
|
videoDisplay.style.display = 'block';
|
||||||
|
videoDisplay.play();
|
||||||
|
|
||||||
|
// When video ends, move to next
|
||||||
|
videoDisplay.onended = () => {
|
||||||
|
currentIndex = (currentIndex + 1) % playlist.length;
|
||||||
|
playNext();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Image or PDF
|
||||||
|
imgDisplay.src = item.url;
|
||||||
|
imgDisplay.style.display = 'block';
|
||||||
|
imgDisplay.classList.add('active');
|
||||||
|
|
||||||
|
// Clear any existing timer
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
|
||||||
|
// Show for specified duration
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
currentIndex = (currentIndex + 1) % playlist.length;
|
||||||
|
playNext();
|
||||||
|
}, (item.duration || 10) * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartPlaylist() {
|
||||||
|
currentIndex = 0;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
videoDisplay.pause();
|
||||||
|
playNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-hide controls after 5 seconds of inactivity
|
||||||
|
function resetInactivityTimer() {
|
||||||
|
// Show controls
|
||||||
|
controls.style.opacity = '1';
|
||||||
|
controls.style.pointerEvents = 'auto';
|
||||||
|
|
||||||
|
// Clear existing timer
|
||||||
|
if (inactivityTimer) {
|
||||||
|
clearTimeout(inactivityTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timer to hide controls after 5 seconds
|
||||||
|
inactivityTimer = setTimeout(() => {
|
||||||
|
controls.style.opacity = '0';
|
||||||
|
controls.style.pointerEvents = 'none';
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track user activity to show/hide controls
|
||||||
|
document.addEventListener('mousemove', resetInactivityTimer);
|
||||||
|
document.addEventListener('mousedown', resetInactivityTimer);
|
||||||
|
document.addEventListener('keydown', resetInactivityTimer);
|
||||||
|
document.addEventListener('touchstart', resetInactivityTimer);
|
||||||
|
|
||||||
|
// Start playing when page loads
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
playNext();
|
||||||
|
resetInactivityTimer(); // Start inactivity timer
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'f' || e.key === 'F') {
|
||||||
|
toggleFullscreen();
|
||||||
|
} else if (e.key === 'r' || e.key === 'R') {
|
||||||
|
restartPlaylist();
|
||||||
|
} else if (e.key === 'Escape' && document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -3,6 +3,129 @@
|
|||||||
{% block title %}Players - DigiServer v2{% endblock %}
|
{% block title %}Players - DigiServer v2{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
body.dark-mode h1 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.players-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.players-table thead tr {
|
||||||
|
background: #f8f9fa;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .players-table thead tr {
|
||||||
|
background: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.players-table th {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .players-table th {
|
||||||
|
border-bottom-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.players-table tbody tr {
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .players-table tbody tr {
|
||||||
|
border-bottom-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.players-table tbody tr:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .players-table tbody tr:hover {
|
||||||
|
background: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.players-table td {
|
||||||
|
padding: 12px;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .players-table td {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.players-table td strong {
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .players-table td strong {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.players-table code {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .players-table code {
|
||||||
|
background: #1a202c;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.online {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.offline {
|
||||||
|
background: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .text-muted {
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: #d1ecf1;
|
||||||
|
border: 1px solid #bee5eb;
|
||||||
|
color: #0c5460;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .info-box {
|
||||||
|
background: #1a365d;
|
||||||
|
border-color: #2c5282;
|
||||||
|
color: #90cdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box a {
|
||||||
|
color: #0c5460;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .info-box a {
|
||||||
|
color: #90cdf4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
<h1>Players</h1>
|
<h1>Players</h1>
|
||||||
@@ -11,49 +134,49 @@
|
|||||||
|
|
||||||
{% if players %}
|
{% if players %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
<table class="players-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background: #f8f9fa; text-align: left;">
|
<tr>
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Name</th>
|
<th>Name</th>
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Hostname</th>
|
<th>Hostname</th>
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Location</th>
|
<th>Location</th>
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Orientation</th>
|
<th>Orientation</th>
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Status</th>
|
<th>Status</th>
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Last Seen</th>
|
<th>Last Seen</th>
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for player in players %}
|
{% for player in players %}
|
||||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
<tr>
|
||||||
<td style="padding: 12px;">
|
<td>
|
||||||
<strong>{{ player.name }}</strong>
|
<strong>{{ player.name }}</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 12px;">
|
<td>
|
||||||
<code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">{{ player.hostname }}</code>
|
<code>{{ player.hostname }}</code>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 12px;">
|
<td>
|
||||||
{{ player.location or '-' }}
|
{{ player.location or '-' }}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 12px;">
|
<td>
|
||||||
{{ player.orientation or 'Landscape' }}
|
{{ player.orientation or 'Landscape' }}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 12px;">
|
<td>
|
||||||
{% set status = player_statuses.get(player.id, {}) %}
|
{% set status = player_statuses.get(player.id, {}) %}
|
||||||
{% if status.get('is_online') %}
|
{% if status.get('is_online') %}
|
||||||
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">Online</span>
|
<span class="status-badge online">Online</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">Offline</span>
|
<span class="status-badge offline">Offline</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 12px;">
|
<td>
|
||||||
{% if player.last_heartbeat %}
|
{% if player.last_heartbeat %}
|
||||||
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M') }}
|
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span style="color: #6c757d;">Never</span>
|
<span class="text-muted">Never</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 12px;">
|
<td>
|
||||||
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||||
class="btn btn-info btn-sm" title="Manage Player">
|
class="btn btn-info btn-sm" title="Manage Player">
|
||||||
⚙️ Manage
|
⚙️ Manage
|
||||||
@@ -65,8 +188,8 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px;">
|
<div class="info-box">
|
||||||
ℹ️ No players yet. <a href="{{ url_for('players.add_player') }}" style="color: #0c5460; text-decoration: underline;">Add your first player</a>
|
ℹ️ No players yet. <a href="{{ url_for('players.add_player') }}">Add your first player</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -219,6 +219,92 @@
|
|||||||
.duration-input {
|
.duration-input {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-input:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-input:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-duration-btn {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
body.dark-mode .playlist-section {
|
||||||
|
background: #2d3748;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .section-header h2 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .section-header {
|
||||||
|
border-bottom-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .playlist-table thead {
|
||||||
|
background: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .playlist-table th {
|
||||||
|
color: #cbd5e0;
|
||||||
|
border-bottom-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .playlist-table td {
|
||||||
|
border-bottom-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .playlist-table tr:hover {
|
||||||
|
background: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .form-control {
|
||||||
|
background: #1a202c;
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .form-control:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .add-content-form {
|
||||||
|
background: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .form-group label {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .empty-state {
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .drag-handle {
|
||||||
|
color: #718096;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -290,9 +376,9 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="playlist-tbody">
|
<tbody id="playlist-tbody">
|
||||||
{% for content in playlist_content %}
|
{% for content in playlist_content %}
|
||||||
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
|
<tr class="draggable-row" data-content-id="{{ content.id }}">
|
||||||
<td>
|
<td>
|
||||||
<span class="drag-handle">⋮⋮</span>
|
<span class="drag-handle" draggable="true">⋮⋮</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ loop.index }}</td>
|
<td>{{ loop.index }}</td>
|
||||||
<td>{{ content.filename }}</td>
|
<td>{{ content.filename }}</td>
|
||||||
@@ -304,11 +390,28 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="number"
|
<div style="display: flex; align-items: center; gap: 5px;">
|
||||||
class="form-control duration-input"
|
<input type="number"
|
||||||
value="{{ content.duration }}"
|
class="form-control duration-input"
|
||||||
min="1"
|
id="duration-{{ content.id }}"
|
||||||
onchange="updateDuration({{ content.id }}, this.value)">
|
value="{{ content._playlist_duration }}"
|
||||||
|
min="1"
|
||||||
|
draggable="false"
|
||||||
|
onclick="event.stopPropagation()"
|
||||||
|
onmousedown="event.stopPropagation()"
|
||||||
|
oninput="markDurationChanged({{ content.id }})"
|
||||||
|
onkeypress="if(event.key==='Enter') saveDuration({{ content.id }})"
|
||||||
|
style="width: 60px; padding: 5px 8px;">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-success btn-sm save-duration-btn"
|
||||||
|
id="save-btn-{{ content.id }}"
|
||||||
|
onclick="event.stopPropagation(); saveDuration({{ content.id }})"
|
||||||
|
onmousedown="event.stopPropagation()"
|
||||||
|
style="display: none; padding: 5px 10px; font-size: 12px; cursor: pointer;"
|
||||||
|
title="Save duration (or press Enter)">
|
||||||
|
💾
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
|
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -335,7 +438,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Content Section -->
|
<!-- Add Content Section -->
|
||||||
{% if available_files %}
|
{% if available_content %}
|
||||||
<div class="playlist-section">
|
<div class="playlist-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>➕ Add Existing Content</h2>
|
<h2>➕ Add Existing Content</h2>
|
||||||
@@ -344,11 +447,11 @@
|
|||||||
<form method="POST" action="{{ url_for('playlist.add_to_playlist', player_id=player.id) }}"
|
<form method="POST" action="{{ url_for('playlist.add_to_playlist', player_id=player.id) }}"
|
||||||
class="add-content-form">
|
class="add-content-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="filename">Select File:</label>
|
<label for="content_id">Select Content:</label>
|
||||||
<select name="filename" id="filename" class="form-control" required>
|
<select name="content_id" id="content_id" class="form-control" required>
|
||||||
<option value="" disabled selected>Choose a file...</option>
|
<option value="" disabled selected>Choose content...</option>
|
||||||
{% for filename in available_files %}
|
{% for content in available_content %}
|
||||||
<option value="{{ filename }}">{{ filename }}</option>
|
<option value="{{ content.id }}">{{ content.filename }} ({{ content.content_type }})</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -380,20 +483,41 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const tbody = document.getElementById('playlist-tbody');
|
const tbody = document.getElementById('playlist-tbody');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
const rows = tbody.querySelectorAll('.draggable-row');
|
// Set up drag handles
|
||||||
|
const dragHandles = tbody.querySelectorAll('.drag-handle');
|
||||||
|
dragHandles.forEach(handle => {
|
||||||
|
handle.addEventListener('dragstart', handleDragStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up drop zones on rows
|
||||||
|
const rows = tbody.querySelectorAll('.draggable-row');
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
row.addEventListener('dragstart', handleDragStart);
|
|
||||||
row.addEventListener('dragover', handleDragOver);
|
row.addEventListener('dragover', handleDragOver);
|
||||||
row.addEventListener('drop', handleDrop);
|
row.addEventListener('drop', handleDrop);
|
||||||
row.addEventListener('dragend', handleDragEnd);
|
row.addEventListener('dragend', handleDragEnd);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Prevent dragging from inputs and buttons
|
||||||
|
const inputs = document.querySelectorAll('.duration-input, button');
|
||||||
|
inputs.forEach(input => {
|
||||||
|
input.addEventListener('mousedown', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
input.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleDragStart(e) {
|
function handleDragStart(e) {
|
||||||
draggedElement = this;
|
// Get the parent row
|
||||||
this.classList.add('dragging');
|
const row = e.target.closest('.draggable-row');
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
draggedElement = row;
|
||||||
|
row.classList.add('dragging');
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/html', row.innerHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragOver(e) {
|
function handleDragOver(e) {
|
||||||
@@ -464,7 +588,41 @@ function saveOrder() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDuration(contentId, duration) {
|
function markDurationChanged(contentId) {
|
||||||
|
const saveBtn = document.getElementById(`save-btn-${contentId}`);
|
||||||
|
const input = document.getElementById(`duration-${contentId}`);
|
||||||
|
|
||||||
|
// Show save button if value changed
|
||||||
|
if (input.value !== input.defaultValue) {
|
||||||
|
saveBtn.style.display = 'inline-block';
|
||||||
|
input.style.borderColor = '#ffc107';
|
||||||
|
} else {
|
||||||
|
saveBtn.style.display = 'none';
|
||||||
|
input.style.borderColor = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDuration(contentId) {
|
||||||
|
const inputElement = document.getElementById(`duration-${contentId}`);
|
||||||
|
const saveBtn = document.getElementById(`save-btn-${contentId}`);
|
||||||
|
const duration = parseInt(inputElement.value);
|
||||||
|
|
||||||
|
// Validate duration
|
||||||
|
if (duration < 1) {
|
||||||
|
alert('Duration must be at least 1 second');
|
||||||
|
inputElement.value = inputElement.defaultValue;
|
||||||
|
inputElement.style.borderColor = '';
|
||||||
|
saveBtn.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalValue = inputElement.defaultValue;
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
inputElement.disabled = true;
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = '⏳';
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('duration', duration);
|
formData.append('duration', duration);
|
||||||
|
|
||||||
@@ -476,15 +634,65 @@ function updateDuration(contentId, duration) {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
console.log('Duration updated successfully');
|
console.log('Duration updated successfully');
|
||||||
// Update total duration
|
inputElement.style.borderColor = '#28a745';
|
||||||
location.reload();
|
inputElement.defaultValue = duration;
|
||||||
|
saveBtn.textContent = '✓';
|
||||||
|
|
||||||
|
// Update total duration display
|
||||||
|
updateTotalDuration();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
inputElement.style.borderColor = '';
|
||||||
|
inputElement.disabled = false;
|
||||||
|
saveBtn.style.display = 'none';
|
||||||
|
saveBtn.textContent = '💾';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}, 1500);
|
||||||
} else {
|
} else {
|
||||||
|
inputElement.style.borderColor = '#dc3545';
|
||||||
|
inputElement.value = originalValue;
|
||||||
|
saveBtn.textContent = '✖';
|
||||||
alert('Error updating duration: ' + data.message);
|
alert('Error updating duration: ' + data.message);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
inputElement.disabled = false;
|
||||||
|
inputElement.style.borderColor = '';
|
||||||
|
saveBtn.style.display = 'none';
|
||||||
|
saveBtn.textContent = '💾';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}, 1500);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
inputElement.style.borderColor = '#dc3545';
|
||||||
|
inputElement.value = originalValue;
|
||||||
|
saveBtn.textContent = '✖';
|
||||||
alert('Error updating duration');
|
alert('Error updating duration');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
inputElement.disabled = false;
|
||||||
|
inputElement.style.borderColor = '';
|
||||||
|
saveBtn.style.display = 'none';
|
||||||
|
saveBtn.textContent = '💾';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTotalDuration() {
|
||||||
|
const durationInputs = document.querySelectorAll('.duration-input');
|
||||||
|
let total = 0;
|
||||||
|
durationInputs.forEach(input => {
|
||||||
|
total += parseInt(input.value) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const statValues = document.querySelectorAll('.stat-value');
|
||||||
|
statValues.forEach((element, index) => {
|
||||||
|
const label = element.parentElement.querySelector('.stat-label');
|
||||||
|
if (label && label.textContent.includes('Total Duration')) {
|
||||||
|
element.textContent = total + 's';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Add orientation column to playlist table - Direct SQLite approach"""
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
|
|
||||||
def add_orientation_column():
|
|
||||||
"""Add orientation column to playlist table using direct SQLite connection."""
|
|
||||||
db_path = 'instance/dev.db'
|
|
||||||
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
print(f"❌ Database not found at: {db_path}")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"📁 Opening database: {db_path}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Connect to database
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Check if column already exists
|
|
||||||
cursor.execute("PRAGMA table_info(playlist)")
|
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
|
||||||
|
|
||||||
if 'orientation' in columns:
|
|
||||||
print("✅ Column 'orientation' already exists in playlist table")
|
|
||||||
conn.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Add the column
|
|
||||||
print("Adding 'orientation' column to playlist table...")
|
|
||||||
cursor.execute("""
|
|
||||||
ALTER TABLE playlist
|
|
||||||
ADD COLUMN orientation VARCHAR(20) DEFAULT 'Landscape' NOT NULL
|
|
||||||
""")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
print("✅ Successfully added 'orientation' column to playlist table")
|
|
||||||
print(" Default value: 'Landscape'")
|
|
||||||
|
|
||||||
# Verify the column was added
|
|
||||||
cursor.execute("PRAGMA table_info(playlist)")
|
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
|
||||||
if 'orientation' in columns:
|
|
||||||
print("✓ Verified: Column exists in database")
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
except sqlite3.Error as e:
|
|
||||||
print(f"❌ SQLite Error: {e}")
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Error: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
add_orientation_column()
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Quick Test - DigiServer v2
|
|
||||||
# Simple test script without virtual environment
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🧪 DigiServer v2 - Quick Test"
|
|
||||||
echo "=============================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
cd /home/pi/Desktop/digiserver-v2
|
|
||||||
|
|
||||||
# Check if database exists
|
|
||||||
if [ ! -f "instance/dashboard.db" ]; then
|
|
||||||
echo "🗄️ Creating database..."
|
|
||||||
export FLASK_APP=app.app:create_app
|
|
||||||
python3 -c "
|
|
||||||
from app.app import create_app
|
|
||||||
from app.extensions import db
|
|
||||||
from app.models import User
|
|
||||||
from flask_bcrypt import bcrypt
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
with app.app_context():
|
|
||||||
db.create_all()
|
|
||||||
# Create admin user
|
|
||||||
admin = User.query.filter_by(username='admin').first()
|
|
||||||
if not admin:
|
|
||||||
hashed = bcrypt.generate_password_hash('admin123').decode('utf-8')
|
|
||||||
admin = User(username='admin', password=hashed, role='admin')
|
|
||||||
db.session.add(admin)
|
|
||||||
db.session.commit()
|
|
||||||
print('✅ Admin user created (admin/admin123)')
|
|
||||||
else:
|
|
||||||
print('✅ Admin user already exists')
|
|
||||||
"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "🚀 Starting Flask server..."
|
|
||||||
echo "📍 URL: http://localhost:5000"
|
|
||||||
echo "👤 Login: admin / admin123"
|
|
||||||
echo ""
|
|
||||||
echo "Press Ctrl+C to stop"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
export FLASK_APP=app.app:create_app
|
|
||||||
export FLASK_ENV=development
|
|
||||||
python3 -m flask run --host=0.0.0.0 --port=5000
|
|
||||||
52
reinit_db.sh
52
reinit_db.sh
@@ -1,52 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Script to reinitialize database with new Player schema
|
|
||||||
|
|
||||||
cd /home/pi/Desktop/digiserver-v2
|
|
||||||
|
|
||||||
echo "🗑️ Removing old database..."
|
|
||||||
rm -f instance/dev.db
|
|
||||||
|
|
||||||
echo "🚀 Recreating database with new schema..."
|
|
||||||
.venv/bin/python << 'EOF'
|
|
||||||
from app.app import create_app
|
|
||||||
from app.extensions import db, bcrypt
|
|
||||||
from app.models import User, Player
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
print('🚀 Creating Flask app...')
|
|
||||||
app = create_app()
|
|
||||||
|
|
||||||
with app.app_context():
|
|
||||||
print('🗄️ Creating database tables...')
|
|
||||||
db.create_all()
|
|
||||||
print('✅ Database tables created')
|
|
||||||
|
|
||||||
# Create admin user
|
|
||||||
hashed = bcrypt.generate_password_hash('admin123').decode('utf-8')
|
|
||||||
admin = User(username='admin', password=hashed, role='admin')
|
|
||||||
db.session.add(admin)
|
|
||||||
|
|
||||||
# Create example player
|
|
||||||
player = Player(
|
|
||||||
name='Demo Player',
|
|
||||||
hostname='player-001',
|
|
||||||
location='Main Office',
|
|
||||||
auth_code=secrets.token_urlsafe(32),
|
|
||||||
orientation='Landscape'
|
|
||||||
)
|
|
||||||
player.set_password('demo123')
|
|
||||||
player.set_quickconnect_code('QUICK123')
|
|
||||||
db.session.add(player)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
print('✅ Admin user created (admin/admin123)')
|
|
||||||
print('✅ Demo player created:')
|
|
||||||
print(f' - Hostname: player-001')
|
|
||||||
print(f' - Password: demo123')
|
|
||||||
print(f' - Quick Connect: QUICK123')
|
|
||||||
print(f' - Auth Code: {player.auth_code}')
|
|
||||||
|
|
||||||
print('')
|
|
||||||
print('🎉 Database ready!')
|
|
||||||
print('📍 Restart Flask server to use new database')
|
|
||||||
EOF
|
|
||||||
BIN
static/uploads/130414-746934884.mp4
Normal file
BIN
static/uploads/130414-746934884.mp4
Normal file
Binary file not shown.
Reference in New Issue
Block a user