5 Commits

Author SHA1 Message Date
jamie d03ba848a0 Merge pull request 'v1.1.0' (#6) from v1.1.0 into master
Reviewed-on: #6
2026-01-16 01:01:17 +00:00
jamie c558ce6961 docs: update README to enhance feature descriptions and add installation instructions
Release / release (pull_request) Successful in 1m17s
2026-01-16 01:00:25 +00:00
jamie c91319de2b feat: frontend rewrite
- Rewrote UI in MD3
- Added Reports page
2026-01-16 00:53:59 +00:00
jamie e4f5ce70b8 Merge pull request 'refactor: streamline ffmpeg command construction in VideoEncoder' (#5) from v1.0.2 into master
Reviewed-on: #5
2026-01-08 20:47:49 +00:00
jamie 8ee7f9fefe refactor: streamline ffmpeg command construction in VideoEncoder
Release / release (pull_request) Successful in 1m14s
2026-01-08 20:46:57 +00:00
12 changed files with 2655 additions and 675 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
] ]
} }
}, },
"postCreateCommand": "sudo apt update; sudo apt install ffmpeg -y; pip install -r requirements.txt; curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64; chmod +x tailwindcss-linux-x64; mv tailwindcss-linux-x64 tailwindcss", "postCreateCommand": "sudo apt update; sudo apt install ffmpeg -y; pip install -r requirements.txt",
"forwardPorts": [5000], "forwardPorts": [5000],
"remoteUser": "vscode" "remoteUser": "vscode"
} }
-5
View File
@@ -7,10 +7,5 @@ ENV VERSION=${VERSION}
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
RUN apt-get update && apt-get install -y curl ffmpeg procps RUN apt-get update && apt-get install -y curl ffmpeg procps
RUN rm -rf /var/lib/apt/lists/* RUN rm -rf /var/lib/apt/lists/*
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
&& chmod +x tailwindcss-linux-x64 \
&& mv tailwindcss-linux-x64 tailwindcss \
&& ./tailwindcss -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html" --minify \
&& rm tailwindcss
EXPOSE 5000 EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app", "--log-level", "info"] CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app", "--log-level", "info"]
+30 -4
View File
@@ -1,14 +1,40 @@
# Encoder # Encoder
A Flask-based media encoding application that automatically converts video files from H.264 to H.265 and audio files from FLAC to MP3, with a dark-themed web interface. A Flask-based media encoding application using ffmpeg and a lovely web interface.
## Features ## Features
- **Video Encoding**: Converts H.264 videos to H.265 (HEVC) in MKV format - **Video Encoding**: Converts video from H264 to H265 (HEVC) at either original resolution or target resolution
- **Audio Encoding**: Converts FLAC audio files to MP3 320kbps - **Audio Encoding**: Converts audio files to MP3 at your chosen bitrate
- **Watch Folders**: Automatically monitors specified folders for new files - **Watch Folders**: Automatically monitors specified folders for new files
- **Job Queue**: Processes files sequentially with real-time status updates - **Job Queue**: Processes files sequentially with real-time status updates
- **Database Tracking**: Stores job reports and configuration in MySQL - **Database Tracking**: Stores job reports and configuration in MySQL
- **Space Savings**: Tracks file size differences to show storage saved - **Space Savings**: Tracks file size differences to show storage saved
- **Web Interface**: Dark-themed responsive UI with live updates - **Web Interface**: Responsive UI with live updates
- **Job Reports**: Detailed history with pagination and statistics - **Job Reports**: Detailed history with pagination and statistics
## Installation
### docker-compose.yaml
```
services:
encoder:
image: cr.jdbnet.co.uk/public/encoder:latest
container_name: encoder
restart: always
ports:
- 5000:5000
environment:
- DB_HOST=your-mysql-or-mariadb-host
- DB_USER=database-user
- DB_PASSWORD=database-password
- DB_NAME=encoder
- SECRET_KEY=set-your-secret-key-here
volumes:
- encoder-temp:/temp
- /path/to/your/local/media:/media
volumes:
encoder-temp:
```
+200 -6
View File
@@ -9,6 +9,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
import mimetypes import mimetypes
import logging import logging
from decimal import Decimal
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from dotenv import load_dotenv from dotenv import load_dotenv
import shutil import shutil
@@ -314,6 +315,180 @@ class DatabaseManager:
conn.close() conn.close()
return result return result
def get_reports_dashboard_data(self):
"""Aggregate data for the reports dashboard"""
conn = self.get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT
COUNT(*) as total_jobs,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_jobs,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_jobs,
SUM(CASE WHEN status = 'skipped' THEN 1 ELSE 0 END) as skipped_jobs,
SUM(CASE WHEN status = 'success' THEN size_saved ELSE 0 END) as total_saved,
SUM(CASE WHEN status = 'success' THEN original_size ELSE 0 END) as total_original,
SUM(CASE WHEN status = 'success' THEN encoded_size ELSE 0 END) as total_encoded,
AVG(CASE WHEN status = 'success' THEN processing_time END) as avg_processing_time
FROM job_reports
""")
summary_raw = cursor.fetchone() or {}
summary = {
'total_jobs': int(summary_raw.get('total_jobs') or 0),
'success_jobs': int(summary_raw.get('success_jobs') or 0),
'failed_jobs': int(summary_raw.get('failed_jobs') or 0),
'skipped_jobs': int(summary_raw.get('skipped_jobs') or 0),
'total_saved': float(summary_raw.get('total_saved') or 0),
'total_original': float(summary_raw.get('total_original') or 0),
'total_encoded': float(summary_raw.get('total_encoded') or 0),
'avg_processing_time': float(summary_raw.get('avg_processing_time') or 0)
}
cursor.execute("""
SELECT DATE(created_at) as day, SUM(size_saved) as total_saved
FROM job_reports
WHERE status = 'success'
GROUP BY day
ORDER BY day
""")
savings_over_time = [
{
'day': row.get('day').strftime('%Y-%m-%d') if row.get('day') else None,
'total_saved': float(row.get('total_saved') or 0)
}
for row in cursor.fetchall()
]
cursor.execute("""
SELECT DATE(created_at) as day,
COUNT(*) as total_jobs,
SUM(status = 'success') as success_jobs,
SUM(status = 'failed') as failed_jobs,
SUM(status = 'skipped') as skipped_jobs
FROM job_reports
GROUP BY day
ORDER BY day
""")
jobs_over_time = [
{
'day': row.get('day').strftime('%Y-%m-%d') if row.get('day') else None,
'total_jobs': int(row.get('total_jobs') or 0),
'success_jobs': int(row.get('success_jobs') or 0),
'failed_jobs': int(row.get('failed_jobs') or 0),
'skipped_jobs': int(row.get('skipped_jobs') or 0)
}
for row in cursor.fetchall()
]
cursor.execute("""
SELECT DATE(created_at) as day,
AVG(processing_time) as avg_processing_time
FROM job_reports
WHERE status = 'success' AND processing_time IS NOT NULL
GROUP BY day
ORDER BY day
""")
processing_time_over_time = [
{
'day': row.get('day').strftime('%Y-%m-%d') if row.get('day') else None,
'avg_processing_time': float(row.get('avg_processing_time') or 0)
}
for row in cursor.fetchall()
]
cursor.execute("""
SELECT encoder_type,
COUNT(*) as total_jobs,
SUM(CASE WHEN status = 'success' THEN size_saved ELSE 0 END) as total_saved
FROM job_reports
GROUP BY encoder_type
""")
savings_by_encoder = [
{
'encoder_type': row.get('encoder_type'),
'total_jobs': int(row.get('total_jobs') or 0),
'total_saved': float(row.get('total_saved') or 0)
}
for row in cursor.fetchall()
]
cursor.execute("""
SELECT original_format, encoded_format,
COUNT(*) as total_jobs,
SUM(CASE WHEN status = 'success' THEN size_saved ELSE 0 END) as total_saved
FROM job_reports
GROUP BY original_format, encoded_format
ORDER BY total_jobs DESC
LIMIT 10
""")
format_breakdown = [
{
'original_format': row.get('original_format'),
'encoded_format': row.get('encoded_format'),
'total_jobs': int(row.get('total_jobs') or 0),
'total_saved': float(row.get('total_saved') or 0)
}
for row in cursor.fetchall()
]
cursor.execute("""
SELECT file_path, size_saved
FROM job_reports
WHERE status = 'success' AND size_saved IS NOT NULL
""")
folder_rows = cursor.fetchall()
folder_sums = {}
for row in folder_rows:
folder = os.path.dirname(row.get('file_path') or '') or '/'
size_saved = row.get('size_saved') or 0
if isinstance(size_saved, Decimal):
size_saved = float(size_saved)
folder_sums[folder] = folder_sums.get(folder, 0) + size_saved
top_folders = [
{'folder': folder, 'total_saved': total}
for folder, total in sorted(folder_sums.items(), key=lambda x: x[1], reverse=True)[:8]
]
cursor.execute("""
SELECT error_message, COUNT(*) as total_errors
FROM job_reports
WHERE status = 'failed' AND error_message IS NOT NULL AND error_message != ''
GROUP BY error_message
ORDER BY total_errors DESC
LIMIT 8
""")
top_errors = [
{
'error_message': row.get('error_message'),
'total_errors': int(row.get('total_errors') or 0)
}
for row in cursor.fetchall()
]
cursor.execute("""
SELECT encoder_type, file_path, status, size_saved, created_at, processing_time
FROM job_reports
ORDER BY created_at DESC
LIMIT 8
""")
recent_jobs = cursor.fetchall()
cursor.close()
conn.close()
return {
'summary': summary,
'savings_over_time': savings_over_time,
'jobs_over_time': jobs_over_time,
'processing_time_over_time': processing_time_over_time,
'savings_by_encoder': savings_by_encoder,
'format_breakdown': format_breakdown,
'top_folders': top_folders,
'top_errors': top_errors,
'recent_jobs': recent_jobs
}
def file_already_processed(self, file_path): def file_already_processed(self, file_path):
"""Check if a file has already been processed (successfully or failed)""" """Check if a file has already been processed (successfully or failed)"""
conn = self.get_connection() conn = self.get_connection()
@@ -389,8 +564,7 @@ class VideoEncoder:
return False, "Invalid video resolution information" return False, "Invalid video resolution information"
cmd = [ cmd = [
'ffmpeg', '-i', input_path, '-c:v', 'libx265', '-c:a', 'copy', 'ffmpeg', '-i', input_path
'-preset', 'medium', '-crf', '28', '-f', 'matroska'
] ]
# Add scaling filter if target resolution is specified # Add scaling filter if target resolution is specified
@@ -404,8 +578,10 @@ class VideoEncoder:
# Ensure width is divisible by 2 (required by video codecs) # Ensure width is divisible by 2 (required by video codecs)
if scale_width % 2 != 0: if scale_width % 2 != 0:
scale_width -= 1 scale_width -= 1
cmd.insert(2, f'scale={scale_width}:{target_height}') cmd.extend(['-vf', f'scale={scale_width}:{target_height}'])
cmd.insert(2, '-vf')
cmd.extend(['-c:v', 'libx265', '-c:a', 'copy',
'-preset', 'medium', '-crf', '28', '-f', 'matroska'])
# Insert user flags if provided # Insert user flags if provided
if ffmpeg_flags: if ffmpeg_flags:
@@ -734,8 +910,9 @@ def process_file(encoder_type, file_path, encoder):
except Exception as report_error: except Exception as report_error:
logger.error(f"Failed to add job report: {report_error}", exc_info=True) logger.error(f"Failed to add job report: {report_error}", exc_info=True)
finally: finally:
# Remove from queue after processing is complete # Remove from queue after processing is complete (only when encoding is enabled)
db_manager.remove_from_queue(encoder_type, file_path) if ENCODING_ENABLED:
db_manager.remove_from_queue(encoder_type, file_path)
with job_lock: with job_lock:
current_jobs[encoder_type] = None current_jobs[encoder_type] = None
@@ -745,6 +922,9 @@ def worker_thread(encoder_type):
logger.info(f"Started {encoder_type} worker thread") logger.info(f"Started {encoder_type} worker thread")
while True: while True:
if not ENCODING_ENABLED:
time.sleep(1)
continue
with job_lock: with job_lock:
queue = db_manager.get_queue(encoder_type, limit=1) queue = db_manager.get_queue(encoder_type, limit=1)
if queue and not current_jobs[encoder_type]: if queue and not current_jobs[encoder_type]:
@@ -886,6 +1066,20 @@ def reports(encoder_type):
total=total, total=total,
search_term=search_term) search_term=search_term)
@app.route('/reports')
def reports_dashboard():
"""Show overall reports dashboard"""
data = db_manager.get_reports_dashboard_data()
summary = data.get('summary') or {}
total_original = summary.get('total_original') or 0
total_saved = summary.get('total_saved') or 0
savings_ratio = (total_saved / total_original) if total_original else 0
return render_template(
'reports_dashboard.html',
**data,
savings_ratio=savings_ratio
)
@app.route('/config') @app.route('/config')
def config(): def config():
"""Configuration page""" """Configuration page"""
-7
View File
@@ -1,7 +0,0 @@
#!/bin/bash
echo "Generating CSS..."
./tailwindcss -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html" --minify
echo "Starting app..."
python app.py
-1
View File
@@ -1 +0,0 @@
@import "tailwindcss"
File diff suppressed because it is too large Load Diff
+145 -90
View File
@@ -1,92 +1,156 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="dark"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Encoder{% endblock %}</title> <title>{% block title %}Encoder{% endblock %}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<link href="{{ url_for('static', filename='css/output.css') }}" rel="stylesheet">
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}"> <link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script> <link href="{{ url_for('static', filename='css/material-design-3.css') }}" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
<style> <style>
html, body { font-family: 'Inter', system-ui, sans-serif; } .hidden { display: none !important; }
.modern-shadow {
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 4px 0 rgba(0,0,0,0.12);
}
.transition-all { transition: all 0.2s cubic-bezier(.4,0,.2,1); }
</style> </style>
{% block head %}{% endblock %}
</head> </head>
<body class="bg-gray-950 text-white min-h-screen"> <body>
<nav class="bg-gray-950 border-b border-gray-800 modern-shadow" x-data="{ mobileMenuOpen: false }"> <nav>
<div class="max-w-full mx-auto px-8"> <div class="nav-container">
<div class="flex justify-between h-16"> <a href="{{ url_for('index') }}" class="nav-brand">
<div class="flex items-center"> <img src="{{ url_for('static', filename='favicon.png') }}" alt="Encoder" style="height: 2rem; width: auto;">
<a href="{{ url_for('index') }}" class="flex items-center space-x-2"> <span>Encoder</span>
<img src="{{ url_for('static', filename='favicon.png') }}" alt="Encoder" class="w-8 h-8 rounded-lg shadow-md"> </a>
<span class="text-2xl font-bold tracking-tight bg-gradient-to-r from-fuchsia-400 via-sky-400 to-cyan-400 bg-clip-text text-transparent">Encoder</span>
</a> <ul class="nav-links">
</div> <li><a href="{{ url_for('index') }}"><i class="fas fa-gauge-high"></i> <span>Dashboard</span></a></li>
<!-- Desktop Navigation --> <li><a href="{{ url_for('config') }}"><i class="fas fa-sliders"></i> <span>Configuration</span></a></li>
<div class="hidden md:flex items-center space-x-4"> <li><a href="{{ url_for('reports_dashboard') }}"><i class="fas fa-chart-line"></i> <span>Reports</span></a></li>
<a href="{{ url_for('index') }}" class="text-sky-300 hover:text-white px-3 py-2 rounded-full text-base font-semibold flex items-center space-x-2 transition-all"> <li>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/> <span class="material-symbols-rounded" id="theme-icon">dark_mode</span>
</svg>
<span>Dashboard</span>
</a>
<a href="{{ url_for('config') }}" class="text-fuchsia-300 hover:text-white px-3 py-2 rounded-full text-base font-semibold flex items-center space-x-2 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>Configuration</span>
</a>
</div>
<!-- Mobile menu button -->
<div class="md:hidden flex items-center">
<button @click="mobileMenuOpen = !mobileMenuOpen" class="inline-flex items-center justify-center p-2 rounded-full text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white transition-all">
<svg class="h-6 w-6" :class="{'hidden': mobileMenuOpen, 'block': !mobileMenuOpen}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg class="h-6 w-6" :class="{'block': mobileMenuOpen, 'hidden': !mobileMenuOpen}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </li>
</div> </ul>
</div>
<!-- Mobile menu --> <button class="mobile-menu-button" id="mobile-menu-button" aria-label="Menu">
<div x-show="mobileMenuOpen" @click.away="mobileMenuOpen = false" x-transition:enter="transition ease-out duration-100" x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-end="transform opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-end="transform opacity-0 scale-95" class="md:hidden bg-gray-800 border-t border-gray-700"> <span class="material-symbols-rounded">menu</span>
<div class="px-2 pt-2 pb-3 space-y-1"> </button>
<a href="{{ url_for('index') }}" class="text-sky-300 hover:text-white hover:bg-gray-700 block px-3 py-2 rounded-full text-base font-semibold flex items-center space-x-2 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<span>Dashboard</span>
</a>
<a href="{{ url_for('config') }}" class="text-fuchsia-300 hover:text-white hover:bg-gray-700 block px-3 py-2 rounded-full text-base font-semibold flex items-center space-x-2 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>Configuration</span>
</a>
</div>
</div> </div>
</nav> </nav>
<main class="max-w-8xl mx-auto py-8 sm:px-8 lg:px-8">
{% block content %}{% endblock %} <div class="mobile-sidenav-overlay" id="mobile-sidenav-overlay"></div>
<div class="mobile-sidenav" id="mobile-sidenav">
<div class="mobile-sidenav-header">
<a href="{{ url_for('index') }}" class="nav-brand">
<img src="{{ url_for('static', filename='favicon.png') }}" alt="Encoder" style="height: 1.5rem; width: auto;">
<span>Encoder</span>
</a>
<button class="mobile-sidenav-close" id="mobile-sidenav-close" aria-label="Close menu">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<ul class="mobile-sidenav-links">
<li><a href="{{ url_for('index') }}"><i class="fas fa-gauge-high"></i> <span>Dashboard</span></a></li>
<li><a href="{{ url_for('config') }}"><i class="fas fa-sliders"></i> <span>Configuration</span></a></li>
<li><a href="{{ url_for('reports_dashboard') }}"><i class="fas fa-chart-line"></i> <span>Reports</span></a></li>
<li>
<button id="mobile-theme-toggle" aria-label="Toggle theme" style="background: none; border: none;">
<span class="material-symbols-rounded" id="mobile-theme-icon">dark_mode</span>
<span>Toggle Theme</span>
</button>
</li>
</ul>
</div>
<main style="flex: 1; padding: 2rem 0;">
<div class="container-wide">
{% block content %}{% endblock %}
</div>
</main> </main>
<div id="notifications" class="fixed bottom-4 right-4 z-50"></div> <div id="notifications" style="position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 2100;"></div>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
<footer class="w-full mt-12 py-6 bg-gray-950 border-t border-gray-800 text-center text-gray-400 text-sm"> <footer class="site-footer" style="background-color: var(--md-sys-color-surface-container); border-top: 1px solid var(--md-sys-color-outline-variant); padding: 1rem 0; text-align: center; color: var(--md-sys-color-on-surface-variant); font-size: 0.875rem; margin-top: auto;">
<div class="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-2 px-4"> <div class="container">
<span>&copy; <script>document.write(new Date().getFullYear());</script> <a href="https://www.jdbnet.co.uk" target="_blank" class="hover:text-sky-400 transition-all">JDB-NET</a></span> <span>&copy; <script>document.write(new Date().getFullYear());</script> <a href="https://www.jdbnet.co.uk" target="_blank">JDB-NET</a></span>
<a href="https://git.jdbnet.co.uk/jamie/encoder" target="_blank" class="hover:text-sky-400 transition-all">{{ version }}</a> <span style="margin-left: 0.75rem;">
<a href="https://git.jdbnet.co.uk/jamie/encoder" target="_blank">{{ version }}</a>
</span>
</div> </div>
</footer> </footer>
<script> <script>
// Theme Toggle
(function() {
const themeToggle = document.getElementById('theme-toggle');
const mobileThemeToggle = document.getElementById('mobile-theme-toggle');
const themeIcon = document.getElementById('theme-icon');
const mobileThemeIcon = document.getElementById('mobile-theme-icon');
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme') || 'dark';
html.setAttribute('data-theme', savedTheme);
updateThemeIcon(savedTheme);
function updateThemeIcon(theme) {
const icon = theme === 'dark' ? 'light_mode' : 'dark_mode';
if (themeIcon) themeIcon.textContent = icon;
if (mobileThemeIcon) mobileThemeIcon.textContent = icon;
}
function toggleTheme() {
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
}
if (themeToggle) themeToggle.addEventListener('click', toggleTheme);
if (mobileThemeToggle) mobileThemeToggle.addEventListener('click', toggleTheme);
})();
// Mobile Menu Toggle
(function() {
const menuButton = document.getElementById('mobile-menu-button');
const closeButton = document.getElementById('mobile-sidenav-close');
const sidenav = document.getElementById('mobile-sidenav');
const overlay = document.getElementById('mobile-sidenav-overlay');
function openMenu() {
sidenav.classList.add('open');
overlay.classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeMenu() {
sidenav.classList.remove('open');
overlay.classList.remove('open');
document.body.style.overflow = '';
}
if (menuButton) menuButton.addEventListener('click', openMenu);
if (closeButton) closeButton.addEventListener('click', closeMenu);
if (overlay) overlay.addEventListener('click', closeMenu);
const menuLinks = sidenav.querySelectorAll('a');
menuLinks.forEach(link => {
link.addEventListener('click', closeMenu);
});
})();
// Set active nav link
(function() {
const currentPath = window.location.pathname;
const navLinks = document.querySelectorAll('.nav-links a, .mobile-sidenav-links a');
navLinks.forEach(link => {
const href = link.getAttribute('href');
if (href === '/' && currentPath === '/') {
link.classList.add('active');
} else if (href !== '/' && currentPath.startsWith(href)) {
link.classList.add('active');
}
});
})();
// Format seconds to human readable duration (like Python's format_duration) // Format seconds to human readable duration (like Python's format_duration)
function formatDurationJS(seconds) { function formatDurationJS(seconds) {
if (seconds == null || isNaN(seconds)) return 'N/A'; if (seconds == null || isNaN(seconds)) return 'N/A';
@@ -180,18 +244,12 @@
const duration = Math.floor((Date.now() / 1000) - job.start_time); const duration = Math.floor((Date.now() / 1000) - job.start_time);
const durationStr = formatDurationJS(duration); const durationStr = formatDurationJS(duration);
statusElement.innerHTML = ` statusElement.innerHTML = `
<div class="flex items-center space-x-2"> <span class="md-chip"><span class="status-dot green"></span> Processing: ${fileName}</span>
<div class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div> <span style="margin-left: 0.5rem; color: var(--md-sys-color-on-surface-variant); font-size: 0.875rem;">(${durationStr})</span>
<span class="text-sm">Processing: ${fileName}</span>
<span class="text-xs text-gray-400">(${durationStr})</span>
</div>
`; `;
} else { } else {
statusElement.innerHTML = ` statusElement.innerHTML = `
<div class="flex items-center space-x-2"> <span class="md-chip"><span class="status-dot gray"></span> Idle</span>
<div class="w-3 h-3 bg-gray-500 rounded-full"></div>
<span class="text-sm">Idle</span>
</div>
`; `;
} }
} }
@@ -211,29 +269,26 @@
queueContainer.innerHTML = ` queueContainer.innerHTML = `
<div class="space-y-2"> <div class="space-y-2">
${queue.map(filePath => ` ${queue.map(filePath => `
<div class="flex items-center space-x-2 text-sm"> <div class="md-card-outlined" style="padding: 0.5rem 0.75rem;">
<div class="w-2 h-2 bg-yellow-500 rounded-full"></div> <span class="status-dot" style="background: #f59e0b;"></span>
<span class="text-gray-300">${filePath.split('/').pop()}</span> <span style="margin-left: 0.5rem;">${filePath.split('/').pop()}</span>
</div> </div>
`).join('')} `).join('')}
</div> </div>
`; `;
} else { } else {
queueContainer.innerHTML = '<p class="text-gray-400 text-sm">No files in queue</p>'; queueContainer.innerHTML = '<p style="color: var(--md-sys-color-on-surface-variant); text-align: center;">No files in queue</p>';
} }
} }
} }
function showNotification(message, type = 'info') { function showNotification(message, type = 'info') {
const notification = document.createElement('div'); const notification = document.createElement('div');
notification.className = ` notification.className = `md-snackbar ${type}`;
p-4 rounded-lg shadow-lg max-w-sm mb-4 transition-all duration-300
${type === 'success' ? 'bg-green-600' : type === 'error' ? 'bg-red-600' : 'bg-blue-600'}
`;
notification.innerHTML = ` notification.innerHTML = `
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-white text-sm">${message}</span> <span>${message}</span>
<button onclick="this.parentElement.parentElement.remove()" class="text-white hover:text-gray-200"> <button onclick="this.parentElement.parentElement.remove()" class="md-button md-button-text" style="padding: 0.25rem 0.5rem;">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg> </svg>
+233 -272
View File
@@ -3,73 +3,60 @@
{% block title %}Configuration - Encoder{% endblock %} {% block title %}Configuration - Encoder{% endblock %}
{% block content %} {% block content %}
<div class="px-4 sm:px-0 max-w-7xl mx-auto"> <div class="fade-in">
<div class="mb-8"> <div class="flex items-center justify-between mb-6">
<div class="flex items-center justify-between"> <div>
<div> <h1 class="md-display-medium mb-2">Configuration</h1>
<h1 class="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-fuchsia-400 via-sky-400 to-cyan-400 mb-2 tracking-tight">Configuration</h1> <p style="color: var(--md-sys-color-on-surface-variant);">Manage watch folders for video and audio encoding.</p>
<p class="text-gray-300 text-lg">Manage watch folders for video and audio encoding</p>
</div>
<a href="{{ url_for('index') }}" class="bg-gradient-to-r from-sky-600 to-fuchsia-600 hover:from-sky-500 hover:to-fuchsia-500 text-white px-6 py-2 rounded-full text-base shadow-lg font-semibold transition-all">
← Back to Dashboard
</a>
</div> </div>
<a href="{{ url_for('index') }}" class="md-button md-button-outlined">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Video Encoder Configuration --> <div class="md-card md-card-elevated" style="padding: 1.5rem;">
<div class="bg-gray-900 rounded-2xl modern-shadow p-8 transition-all hover:scale-[1.01]"> <h2 class="md-title-large" style="margin-top: 0;">
<h2 class="text-2xl font-bold text-white mb-4 flex items-center"> <i class="fas fa-video" style="color: var(--md-sys-color-primary);"></i>
<svg class="w-7 h-7 mr-2 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
Video Encoder Folders Video Encoder Folders
</h2> </h2>
<!-- Add new folder form --> <form method="POST" action="{{ url_for('add_config') }}" class="mt-4" id="video-config-form">
<form method="POST" action="{{ url_for('add_config') }}" class="mb-6" id="video-config-form">
<input type="hidden" name="encoder_type" value="video"> <input type="hidden" name="encoder_type" value="video">
<div class="flex flex-col space-y-2"> <div class="grid grid-cols-1 gap-4">
<div class="flex space-x-2 items-end"> <div>
<div class="flex-1"> <label style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Watch Folder</label>
<label for="video-folder-picker" class="text-xs text-gray-400 mb-1 block">Watch Folder</label> <div id="video-folder-picker" class="md-card-outlined" style="padding: 0.75rem; cursor: pointer;">
<div id="video-folder-picker" class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white cursor-pointer select-none" style="min-height:2.5rem;"> <span id="video-folder-path">/</span>
<span id="video-folder-path">/</span>
</div>
<input type="hidden" name="watch_folder" id="video-folder-input" value="/">
<div id="video-folder-list" class="bg-gray-800 border border-gray-700 rounded-lg mt-1 p-2 text-white text-sm hidden"></div>
</div> </div>
<div class="flex-1"> <input type="hidden" name="watch_folder" id="video-folder-input" value="/">
<label for="video-temp-picker" class="text-xs text-gray-400 mb-1 block">Temp Directory</label> <div id="video-folder-list" class="md-card-outlined hidden" style="padding: 0.75rem; margin-top: 0.5rem;"></div>
<div id="video-temp-picker" class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white cursor-pointer select-none" style="min-height:2.5rem;">
<span id="video-temp-path">/temp</span>
</div>
<input type="hidden" name="temp_dir" id="video-temp-input" value="/temp">
<div id="video-temp-list" class="bg-gray-800 border border-gray-700 rounded-lg mt-1 p-2 text-white text-sm hidden"></div>
</div>
<div class="flex flex-col">
<label for="video-target-resolution" class="text-xs text-gray-400 mb-1">Target Resolution
<span class="text-gray-500 text-xs">(Optional)</span>
</label>
<select name="target_resolution" id="video-target-resolution" class="bg-gray-700 border border-gray-600 rounded-lg px-2 py-1 text-white">
<option value="">Default (Keep Original)</option>
<option value="480p">480p</option>
<option value="720p">720p</option>
<option value="1080p">1080p</option>
<option value="1440p">1440p</option>
<option value="2160p">4K (2160p)</option>
</select>
</div>
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
Add Folder
</button>
</div> </div>
<div class="flex space-x-2">
<div class="flex flex-col"> <div>
<label for="crf" class="text-xs text-gray-400 mb-1">CRF (Quality) <label style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Temp Directory</label>
<span title="Constant Rate Factor. Lower is better quality. 18-28 typical. Recommended: 22-28 for H.265." class="ml-1 text-blue-300 cursor-help">?</span> <div id="video-temp-picker" class="md-card-outlined" style="padding: 0.75rem; cursor: pointer;">
</label> <span id="video-temp-path">/temp</span>
<select name="crf" id="crf" class="bg-gray-700 border border-gray-600 rounded-lg px-2 py-1 text-white"> </div>
<input type="hidden" name="temp_dir" id="video-temp-input" value="/temp">
<div id="video-temp-list" class="md-card-outlined hidden" style="padding: 0.75rem; margin-top: 0.5rem;"></div>
</div>
<div class="md-text-field">
<select name="target_resolution" id="video-target-resolution">
<option value="">Default (Keep Original)</option>
<option value="480p">480p</option>
<option value="720p">720p</option>
<option value="1080p">1080p</option>
<option value="1440p">1440p</option>
<option value="2160p">4K (2160p)</option>
</select>
<label for="video-target-resolution">Target Resolution</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="md-text-field">
<select name="crf" id="crf">
<option value="18">18</option> <option value="18">18</option>
<option value="20">20</option> <option value="20">20</option>
<option value="22">22</option> <option value="22">22</option>
@@ -77,12 +64,10 @@
<option value="26">26</option> <option value="26">26</option>
<option value="28">28</option> <option value="28">28</option>
</select> </select>
<label for="crf">CRF (Quality)</label>
</div> </div>
<div class="flex flex-col"> <div class="md-text-field">
<label for="preset" class="text-xs text-gray-400 mb-1">Preset <select name="preset" id="preset">
<span title="Encoding speed. Slower = better compression. Recommended: medium/slow." class="ml-1 text-blue-300 cursor-help">?</span>
</label>
<select name="preset" id="preset" class="bg-gray-700 border border-gray-600 rounded-lg px-2 py-1 text-white">
<option value="ultrafast">ultrafast</option> <option value="ultrafast">ultrafast</option>
<option value="superfast">superfast</option> <option value="superfast">superfast</option>
<option value="veryfast">veryfast</option> <option value="veryfast">veryfast</option>
@@ -93,67 +78,59 @@
<option value="slower">slower</option> <option value="slower">slower</option>
<option value="veryslow">veryslow</option> <option value="veryslow">veryslow</option>
</select> </select>
<label for="preset">Preset</label>
</div> </div>
</div> </div>
<button type="submit" class="md-button md-button-filled" style="margin-bottom: 2rem;">
<i class="fas fa-plus"></i> Add Folder
</button>
</div> </div>
</form> </form>
<!-- Current folders --> <div class="mt-6 space-y-3">
<div class="space-y-3 mt-4">
{% if video_configs %} {% if video_configs %}
{% for config in video_configs %} {% for config in video_configs %}
<div class="bg-gray-800 rounded-xl p-4 flex items-center justify-between modern-shadow transition-all hover:scale-[1.01]"> <div class="md-card-outlined" style="padding: 1rem;">
<div> <div class="flex items-center justify-between">
<div class="text-white text-lg font-semibold flex items-center"> <div style="flex: 1;">
<svg class="w-4 h-4 mr-1 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" /></svg> <div style="font-weight: 600;">{{ config.watch_folder }}</div>
{{ config.watch_folder }} <div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">
</div> Added: {{ config.created_at.strftime('%Y-%m-%d %H:%M') }}
<div class="text-gray-400 text-xs">Added: {{ config.created_at.strftime('%Y-%m-%d %H:%M') }}</div>
{% if config.temp_dir %}
<div class="text-blue-200 text-xs mt-1">Temp Dir: <span class="bg-blue-900 px-1 rounded">{{ config.temp_dir }}</span></div>
{% endif %}
{% if config.target_resolution %}
<div class="text-green-200 text-xs mt-1">Resolution: <span class="bg-green-900 px-1 rounded">{{ config.target_resolution }}</span></div>
{% endif %}
{% if config.ffmpeg_flags %}
{% set flags = config.ffmpeg_flags.split() %}
<div class="text-blue-300 text-xs mt-1">
<span title="FFmpeg flags used for this folder">
Flags:
{% for flag in flags %}
<span class="bg-blue-900 px-1 rounded mr-1">{{ flag }}</span>
{% endfor %}
</span>
</div> </div>
{% endif %} {% if config.temp_dir %}
</div> <div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant); margin-top: 0.25rem;">
<div class="flex items-center space-x-2"> Temp Dir: <span class="md-chip">{{ config.temp_dir }}</span>
{% if config.watch_folder | path_exists %} </div>
<span class="w-2 h-2 bg-green-500 rounded-full"></span> {% endif %}
<span class="text-green-400 text-xs font-semibold">Active</span> {% if config.target_resolution %}
{% else %} <div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant); margin-top: 0.25rem;">
<span class="w-2 h-2 bg-red-500 rounded-full"></span> Resolution: <span class="md-chip">{{ config.target_resolution }}</span>
<span class="text-red-400 text-xs font-semibold">Not Found</span> </div>
{% endif %} {% endif %}
<button onclick="editConfig({{ config.id }})" class="ml-2 px-3 py-1 rounded-full bg-sky-700 hover:bg-sky-600 text-white text-xs font-semibold transition-all">Edit</button> </div>
<button onclick="removeConfig({{ config.id }})" class="px-3 py-1 rounded-full bg-red-700 hover:bg-red-600 text-white text-xs font-semibold transition-all">Remove</button> <div class="flex items-center gap-2">
{% if config.watch_folder | path_exists %}
<span class="md-chip"><span class="status-dot green"></span> Active</span>
{% else %}
<span class="md-chip"><span class="status-dot red"></span> Not Found</span>
{% endif %}
<button onclick="editConfig({{ config.id }})" class="md-button md-button-text">Edit</button>
<button onclick="removeConfig({{ config.id }})" class="md-button md-button-text" style="color: var(--md-sys-color-error);">Remove</button>
</div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="bg-gray-800 rounded-xl p-8 text-center modern-shadow"> <div class="md-card-outlined" style="padding: 1.5rem; text-align: center;">
<svg class="mx-auto h-8 w-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <p style="color: var(--md-sys-color-on-surface-variant);">No video folders configured.</p>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"/>
</svg>
<p class="text-gray-400 text-base">No video folders configured</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Configuration info --> <div class="md-card-outlined" style="padding: 1rem; margin-top: 1.5rem;">
<div class="mt-6 bg-blue-900 bg-opacity-20 border border-blue-600 rounded-lg p-4"> <h3 class="md-title-large" style="font-size: 1rem;">Video Encoder Settings</h3>
<h3 class="text-blue-300 font-medium mb-2">Video Encoder Settings</h3> <ul style="font-size: 0.875rem; color: var(--md-sys-color-on-surface-variant);">
<ul class="text-sm text-blue-200 space-y-1">
<li>• Converts H.264 videos to H.265 (HEVC)</li> <li>• Converts H.264 videos to H.265 (HEVC)</li>
<li>• Output format: MKV container</li> <li>• Output format: MKV container</li>
<li>• Supported formats: MP4, AVI, MKV, MOV, WMV, FLV, WebM</li> <li>• Supported formats: MP4, AVI, MKV, MOV, WMV, FLV, WebM</li>
@@ -161,206 +138,184 @@
</div> </div>
</div> </div>
<!-- Audio Encoder Configuration --> <div class="md-card md-card-elevated" style="padding: 1.5rem;">
<div class="bg-gray-900 rounded-2xl modern-shadow p-8 transition-all hover:scale-[1.01]"> <h2 class="md-title-large" style="margin-top: 0;">
<h2 class="text-2xl font-bold text-white mb-4 flex items-center"> <i class="fas fa-music" style="color: var(--md-sys-color-tertiary);"></i>
<svg class="w-7 h-7 mr-2 text-fuchsia-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
Audio Encoder Folders Audio Encoder Folders
</h2> </h2>
<!-- Add new folder form --> <form method="POST" action="{{ url_for('add_config') }}" class="mt-4" id="audio-config-form">
<form method="POST" action="{{ url_for('add_config') }}" class="mb-6" id="audio-config-form">
<input type="hidden" name="encoder_type" value="audio"> <input type="hidden" name="encoder_type" value="audio">
<div class="flex flex-col space-y-2"> <div class="grid grid-cols-1 gap-4">
<div class="flex space-x-2 items-end"> <div>
<div class="flex-1"> <label style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Watch Folder</label>
<label for="audio-folder-picker" class="text-xs text-gray-400 mb-1 block">Watch Folder</label> <div id="audio-folder-picker" class="md-card-outlined" style="padding: 0.75rem; cursor: pointer;">
<div id="audio-folder-picker" class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white cursor-pointer select-none" style="min-height:2.5rem;"> <span id="audio-folder-path">/</span>
<span id="audio-folder-path">/</span>
</div>
<input type="hidden" name="watch_folder" id="audio-folder-input" value="/">
<div id="audio-folder-list" class="bg-gray-800 border border-gray-700 rounded-lg mt-1 p-2 text-white text-sm hidden"></div>
</div> </div>
<div class="flex-1"> <input type="hidden" name="watch_folder" id="audio-folder-input" value="/">
<label for="audio-temp-picker" class="text-xs text-gray-400 mb-1 block">Temp Directory</label> <div id="audio-folder-list" class="md-card-outlined hidden" style="padding: 0.75rem; margin-top: 0.5rem;"></div>
<div id="audio-temp-picker" class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white cursor-pointer select-none" style="min-height:2.5rem;">
<span id="audio-temp-path">/temp</span>
</div>
<input type="hidden" name="temp_dir" id="audio-temp-input" value="/temp">
<div id="audio-temp-list" class="bg-gray-800 border border-gray-700 rounded-lg mt-1 p-2 text-white text-sm hidden"></div>
</div>
<button type="submit" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
Add Folder
</button>
</div> </div>
<div class="flex flex-col">
<label for="audio_bitrate" class="text-xs text-gray-400 mb-1">Bitrate <div>
<span title="Audio bitrate. Higher = better quality. Recommended: 192k-320k." class="ml-1 text-purple-300 cursor-help">?</span> <label style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Temp Directory</label>
</label> <div id="audio-temp-picker" class="md-card-outlined" style="padding: 0.75rem; cursor: pointer;">
<select name="audio_bitrate" id="audio_bitrate" class="bg-gray-700 border border-gray-600 rounded-lg px-2 py-1 text-white"> <span id="audio-temp-path">/temp</span>
</div>
<input type="hidden" name="temp_dir" id="audio-temp-input" value="/temp">
<div id="audio-temp-list" class="md-card-outlined hidden" style="padding: 0.75rem; margin-top: 0.5rem;"></div>
</div>
<div class="md-text-field">
<select name="audio_bitrate" id="audio_bitrate">
<option value="128k">128k</option> <option value="128k">128k</option>
<option value="192k">192k</option> <option value="192k">192k</option>
<option value="256k">256k</option> <option value="256k">256k</option>
<option value="320k" selected>320k</option> <option value="320k" selected>320k</option>
</select> </select>
<label for="audio_bitrate">Bitrate</label>
</div> </div>
<button type="submit" class="md-button md-button-filled" style="margin-bottom: 2rem;">
<i class="fas fa-plus"></i> Add Folder
</button>
</div> </div>
</form> </form>
<!-- Current folders --> <div class="mt-6 space-y-3">
<div class="space-y-3 mt-4">
{% if audio_configs %} {% if audio_configs %}
{% for config in audio_configs %} {% for config in audio_configs %}
<div class="bg-gray-800 rounded-xl p-4 flex items-center justify-between modern-shadow transition-all hover:scale-[1.01]"> <div class="md-card-outlined" style="padding: 1rem;">
<div> <div class="flex items-center justify-between">
<div class="text-white text-lg font-semibold flex items-center"> <div style="flex: 1;">
<svg class="w-4 h-4 mr-1 text-fuchsia-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" /></svg> <div style="font-weight: 600;">{{ config.watch_folder }}</div>
{{ config.watch_folder }} <div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">
</div> Added: {{ config.created_at.strftime('%Y-%m-%d %H:%M') }}
<div class="text-gray-400 text-xs">Added: {{ config.created_at.strftime('%Y-%m-%d %H:%M') }}</div>
{% if config.temp_dir %}
<div class="text-purple-200 text-xs mt-1">Temp Dir: <span class="bg-purple-900 px-1 rounded">{{ config.temp_dir }}</span></div>
{% endif %}
{% if config.target_resolution %}
<div class="text-green-200 text-xs mt-1">Resolution: <span class="bg-green-900 px-1 rounded">{{ config.target_resolution }}</span></div>
{% endif %}
{% if config.ffmpeg_flags %}
{% set flags = config.ffmpeg_flags.split() %}
<div class="text-purple-300 text-xs mt-1">
<span title="FFmpeg flags used for this folder">
Flags:
{% for flag in flags %}
<span class="bg-purple-900 px-1 rounded mr-1">{{ flag }}</span>
{% endfor %}
</span>
</div> </div>
{% endif %} {% if config.temp_dir %}
</div> <div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant); margin-top: 0.25rem;">
<div class="flex items-center space-x-2"> Temp Dir: <span class="md-chip">{{ config.temp_dir }}</span>
{% if config.watch_folder | path_exists %} </div>
<span class="w-2 h-2 bg-green-500 rounded-full"></span> {% endif %}
<span class="text-green-400 text-xs font-semibold">Active</span> </div>
{% else %} <div class="flex items-center gap-2">
<span class="w-2 h-2 bg-red-500 rounded-full"></span> {% if config.watch_folder | path_exists %}
<span class="text-red-400 text-xs font-semibold">Not Found</span> <span class="md-chip"><span class="status-dot green"></span> Active</span>
{% endif %} {% else %}
<button onclick="editConfig({{ config.id }})" class="ml-2 px-3 py-1 rounded-full bg-sky-700 hover:bg-sky-600 text-white text-xs font-semibold transition-all">Edit</button> <span class="md-chip"><span class="status-dot red"></span> Not Found</span>
<button onclick="removeConfig({{ config.id }})" class="px-3 py-1 rounded-full bg-red-700 hover:bg-red-600 text-white text-xs font-semibold transition-all">Remove</button> {% endif %}
<button onclick="editConfig({{ config.id }})" class="md-button md-button-text">Edit</button>
<button onclick="removeConfig({{ config.id }})" class="md-button md-button-text" style="color: var(--md-sys-color-error);">Remove</button>
</div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="bg-gray-800 rounded-xl p-8 text-center modern-shadow"> <div class="md-card-outlined" style="padding: 1.5rem; text-align: center;">
<svg class="mx-auto h-8 w-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <p style="color: var(--md-sys-color-on-surface-variant);">No audio folders configured.</p>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"/>
</svg>
<p class="text-gray-400 text-base">No audio folders configured</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Configuration info --> <div class="md-card-outlined" style="padding: 1rem; margin-top: 1.5rem;">
<div class="mt-6 bg-purple-900 bg-opacity-20 border border-purple-600 rounded-lg p-4"> <h3 class="md-title-large" style="font-size: 1rem;">Audio Encoder Settings</h3>
<h3 class="text-purple-300 font-medium mb-2">Audio Encoder Settings</h3> <ul style="font-size: 0.875rem; color: var(--md-sys-color-on-surface-variant);">
<ul class="text-sm text-purple-200 space-y-1">
<li>• Converts files to MP3</li> <li>• Converts files to MP3</li>
<li>• Supported formats: FLAC, WAV, M4A, OGG</li> <li>• Supported formats: FLAC, WAV, M4A, OGG</li>
<li>• Codec: libmp3lame</li> <li>• Codec: libmp3lame</li>
</ul> </ul>
</div> </div>
</div> </div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<!-- Edit Config Modal --> <div id="edit-config-modal" class="search-modal-overlay hidden">
<div id="edit-config-modal" class="fixed inset-0 z-50 hidden flex items-center justify-center"> <div class="search-modal" style="max-width: 640px;">
<div class="absolute inset-0 bg-black bg-opacity-40 backdrop-blur-sm transition-all"></div> <div class="search-modal-header">
<div class="relative bg-gray-900 rounded-2xl p-8 shadow-2xl max-w-lg w-full mx-auto border border-gray-700 flex flex-col justify-center"> <h2 class="md-title-large" style="margin: 0;">Edit Configuration</h2>
<h2 class="text-2xl font-bold mb-4 text-white">Edit Configuration</h2> </div>
<form id="edit-config-form"> <div style="padding: 1.5rem;">
<input type="hidden" name="id" id="edit-config-id"> <form id="edit-config-form">
<input type="hidden" name="encoder_type" id="edit-encoder-type"> <input type="hidden" name="id" id="edit-config-id">
<div class="mb-4"> <input type="hidden" name="encoder_type" id="edit-encoder-type">
<label class="block text-xs text-gray-400 mb-1">Watch Folder</label>
<div id="edit-folder-picker" class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white cursor-pointer select-none hover:bg-gray-600 transition-all" style="min-height:2.5rem;"> <div style="margin-bottom: 1rem;">
<span id="edit-folder-path">/</span> <label style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Watch Folder</label>
</div> <div id="edit-folder-picker" class="md-card-outlined" style="padding: 0.75rem; cursor: pointer;">
<input type="hidden" name="watch_folder" id="edit-folder-input" value="/"> <span id="edit-folder-path">/</span>
<div id="edit-folder-list" class="bg-gray-800 border border-gray-700 rounded-lg mt-1 p-2 text-white text-sm hidden z-50" style="position:absolute;"></div>
</div>
<div class="mb-4">
<label class="block text-xs text-gray-400 mb-1">Temp Directory</label>
<div id="edit-temp-picker" class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white cursor-pointer select-none hover:bg-gray-600 transition-all" style="min-height:2.5rem;">
<span id="edit-temp-path">/temp</span>
</div>
<input type="hidden" name="temp_dir" id="edit-temp-input" value="/temp">
<div id="edit-temp-list" class="bg-gray-800 border border-gray-700 rounded-lg mt-1 p-2 text-white text-sm hidden z-50" style="position:absolute;"></div>
</div>
<div class="mb-4" id="edit-target-resolution-field" style="display:none;">
<label for="edit-target-resolution" class="text-xs text-gray-400 mb-1">Target Resolution
<span class="text-gray-500 text-xs">(Optional)</span>
</label>
<select name="target_resolution" id="edit-target-resolution" class="bg-gray-700 border border-gray-600 rounded-lg px-2 py-1 text-white w-full">
<option value="">Default (Keep Original)</option>
<option value="480p">480p</option>
<option value="720p">720p</option>
<option value="1080p">1080p</option>
<option value="1440p">1440p</option>
<option value="2160p">4K (2160p)</option>
</select>
</div>
<div class="mb-4" id="edit-ffmpeg-flags-video" style="display:none;">
<label class="block text-xs text-gray-400 mb-1">FFmpeg Flags (Video)</label>
<div class="flex space-x-2">
<div class="flex flex-col">
<label for="edit-crf" class="text-xs text-gray-400 mb-1">CRF (Quality)</label>
<select name="crf" id="edit-crf" class="bg-gray-700 border border-gray-600 rounded-lg px-2 py-1 text-white">
<option value="18">18</option>
<option value="20">20</option>
<option value="22">22</option>
<option value="24">24</option>
<option value="26">26</option>
<option value="28">28</option>
</select>
</div>
<div class="flex flex-col">
<label for="edit-preset" class="text-xs text-gray-400 mb-1">Preset</label>
<select name="preset" id="edit-preset" class="bg-gray-700 border border-gray-600 rounded-lg px-2 py-1 text-white">
<option value="ultrafast">ultrafast</option>
<option value="superfast">superfast</option>
<option value="veryfast">veryfast</option>
<option value="faster">faster</option>
<option value="fast">fast</option>
<option value="medium">medium</option>
<option value="slow">slow</option>
<option value="slower">slower</option>
<option value="veryslow">veryslow</option>
</select>
</div> </div>
<input type="hidden" name="watch_folder" id="edit-folder-input" value="/">
<div id="edit-folder-list" class="md-card-outlined hidden" style="padding: 0.75rem; margin-top: 0.5rem; position: absolute;"></div>
</div> </div>
</div>
<div class="mb-4" id="edit-ffmpeg-flags-audio" style="display:none;"> <div style="margin-bottom: 1rem;">
<label class="block text-xs text-gray-400 mb-1">FFmpeg Flags (Audio)</label> <label style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Temp Directory</label>
<div class="flex flex-col"> <div id="edit-temp-picker" class="md-card-outlined" style="padding: 0.75rem; cursor: pointer;">
<label for="edit-audio-bitrate" class="text-xs text-gray-400 mb-1">Bitrate</label> <span id="edit-temp-path">/temp</span>
<select name="audio_bitrate" id="edit-audio-bitrate" class="bg-gray-700 border border-gray-600 rounded-lg px-2 py-1 text-white"> </div>
<option value="128k">128k</option> <input type="hidden" name="temp_dir" id="edit-temp-input" value="/temp">
<option value="192k">192k</option> <div id="edit-temp-list" class="md-card-outlined hidden" style="padding: 0.75rem; margin-top: 0.5rem; position: absolute;"></div>
<option value="256k">256k</option> </div>
<option value="320k">320k</option>
<div class="md-text-field" id="edit-target-resolution-field" style="display:none;">
<select name="target_resolution" id="edit-target-resolution">
<option value="">Default (Keep Original)</option>
<option value="480p">480p</option>
<option value="720p">720p</option>
<option value="1080p">1080p</option>
<option value="1440p">1440p</option>
<option value="2160p">4K (2160p)</option>
</select> </select>
<label for="edit-target-resolution">Target Resolution</label>
</div> </div>
</div>
<div class="flex justify-end space-x-2 mt-6"> <div id="edit-ffmpeg-flags-video" style="display:none; margin-top: 1rem;">
<button type="button" onclick="closeEditModal()" class="px-4 py-2 rounded-full bg-gray-700 hover:bg-gray-600 text-white font-semibold transition-all">Cancel</button> <div class="grid grid-cols-2 gap-4">
<button type="submit" class="px-4 py-2 rounded-full bg-sky-600 hover:bg-sky-500 text-white font-semibold transition-all">Save</button> <div class="md-text-field">
</div> <select name="crf" id="edit-crf">
</form> <option value="18">18</option>
<option value="20">20</option>
<option value="22">22</option>
<option value="24">24</option>
<option value="26">26</option>
<option value="28">28</option>
</select>
<label for="edit-crf">CRF (Quality)</label>
</div>
<div class="md-text-field">
<select name="preset" id="edit-preset">
<option value="ultrafast">ultrafast</option>
<option value="superfast">superfast</option>
<option value="veryfast">veryfast</option>
<option value="faster">faster</option>
<option value="fast">fast</option>
<option value="medium">medium</option>
<option value="slow">slow</option>
<option value="slower">slower</option>
<option value="veryslow">veryslow</option>
</select>
<label for="edit-preset">Preset</label>
</div>
</div>
</div>
<div id="edit-ffmpeg-flags-audio" style="display:none; margin-top: 1rem;">
<div class="md-text-field">
<select name="audio_bitrate" id="edit-audio-bitrate">
<option value="128k">128k</option>
<option value="192k">192k</option>
<option value="256k">256k</option>
<option value="320k">320k</option>
</select>
<label for="edit-audio-bitrate">Bitrate</label>
</div>
</div>
<div class="flex justify-between" style="margin-top: 1.5rem;">
<button type="button" onclick="closeEditModal()" class="md-button md-button-text">Cancel</button>
<button type="submit" class="md-button md-button-filled">Save Changes</button>
</div>
</form>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
@@ -378,7 +333,7 @@ function setupFolderPicker(pickerId, pathId, inputId, listId) {
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
list.innerHTML = `<div class='text-red-400'>${data.error}</div>`; list.innerHTML = `<div style="color: var(--md-sys-color-error);">${data.error}</div>`;
list.classList.remove('hidden'); list.classList.remove('hidden');
return; return;
} }
@@ -386,7 +341,10 @@ function setupFolderPicker(pickerId, pathId, inputId, listId) {
if (path !== '/') { if (path !== '/') {
const up = document.createElement('div'); const up = document.createElement('div');
up.textContent = '.. (up)'; up.textContent = '.. (up)';
up.className = 'cursor-pointer hover:bg-gray-700 px-2 py-1 rounded'; up.className = 'md-card-outlined';
up.style.cursor = 'pointer';
up.style.padding = '0.5rem';
up.style.marginBottom = '0.25rem';
up.onclick = () => { up.onclick = () => {
const parent = path.replace(/\/$/, '').split('/').slice(0, -1).join('/') || '/'; const parent = path.replace(/\/$/, '').split('/').slice(0, -1).join('/') || '/';
currentPath = parent; currentPath = parent;
@@ -399,7 +357,10 @@ function setupFolderPicker(pickerId, pathId, inputId, listId) {
data.dirs.forEach(dir => { data.dirs.forEach(dir => {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = dir; div.textContent = dir;
div.className = 'cursor-pointer hover:bg-gray-700 px-2 py-1 rounded'; div.className = 'md-card-outlined';
div.style.cursor = 'pointer';
div.style.padding = '0.5rem';
div.style.marginBottom = '0.25rem';
div.onclick = () => { div.onclick = () => {
let newPath = path === '/' ? `/${dir}` : `${path}/${dir}`; let newPath = path === '/' ? `/${dir}` : `${path}/${dir}`;
currentPath = newPath; currentPath = newPath;
+141 -124
View File
@@ -1,171 +1,188 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="px-4 sm:px-0 max-w-7xl mx-auto"> <div class="fade-in">
<div class="mb-8"> <div class="flex items-center justify-between mb-6">
<h1 class="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-fuchsia-400 via-sky-400 to-cyan-400 mb-2 tracking-tight">Encoder Dashboard</h1> <div>
<p class="text-gray-300 text-lg">Monitor your video and audio encoding jobs</p> <h1 class="md-display-medium mb-2">Encoder Dashboard</h1>
<p style="color: var(--md-sys-color-on-surface-variant);">Monitor your video and audio encoding jobs in real time.</p>
</div>
<a href="{{ url_for('reports_dashboard') }}" class="md-button md-button-outlined">
<i class="fas fa-chart-line"></i> Explore Reports
</a>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Video Encoder Column --> <div class="md-card md-card-elevated" style="padding: 1.5rem;">
<div class="bg-gray-900 rounded-2xl modern-shadow p-8 transition-all hover:scale-[1.01]"> <div class="flex items-center justify-between mb-4">
<div class="flex items-center justify-between mb-6"> <h2 class="md-title-large" style="margin: 0;">
<h2 class="text-2xl font-bold text-white flex items-center"> <i class="fas fa-video" style="color: var(--md-sys-color-primary);"></i>
<svg class="w-7 h-7 mr-2 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
Video Encoder Video Encoder
</h2> </h2>
<a href="{{ url_for('reports', encoder_type='video') }}" class="text-sky-400 hover:text-white text-base font-semibold transition-all">View Reports</a> <a href="{{ url_for('reports', encoder_type='video') }}" class="md-button md-button-text">Job Reports</a>
</div> </div>
<!-- Current Job Status --> <div class="md-card-outlined" style="padding: 1rem; margin-bottom: 1rem;">
<div class="mb-6"> <div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white mb-3">Current Job</h3> <div>
<div class="bg-gray-800 rounded-xl p-4"> <div class="md-title-large" style="font-size: 1rem;">Current Job</div>
<div id="video-status"> <div id="video-status" style="margin-top: 0.5rem;">
{% if current_video_job %} {% if current_video_job %}
<div class="flex items-center space-x-2"> <span class="md-chip"><span class="status-dot green"></span> Processing: {{ current_video_job.file_path.split('/')[-1] }}</span>
<div class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div> <span style="margin-left: 0.5rem; color: var(--md-sys-color-on-surface-variant); font-size: 0.875rem;">
<span class="text-sm">Processing: {{ current_video_job.file_path.split('/')[-1] }}</span> ({{ current_video_job_duration|format_duration }})
<span class="text-xs text-gray-400">({{ current_video_job_duration|format_duration }})</span> </span>
</div> {% else %}
{% else %} <span class="md-chip"><span class="status-dot gray"></span> Idle</span>
<div class="flex items-center space-x-2"> {% endif %}
<div class="w-3 h-3 bg-gray-500 rounded-full"></div> </div>
<span class="text-sm">Idle</span> </div>
</div> <div class="md-card-outlined" style="padding: 0.75rem 1rem; text-align: center;">
{% endif %} <div id="video-total-jobs" class="md-title-large" style="font-weight: 700;">{{ video_stats.total_jobs or 0 }}</div>
<div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Total Jobs</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Queue --> <div class="md-card-outlined" style="padding: 1rem; margin-bottom: 1rem;">
<div class="mb-6"> <div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-white mb-3"> <div class="md-title-large" style="font-size: 1rem;">Queue</div>
Queue (<span id="video-queue-length">{{ video_queue_length }}</span>) <span class="md-chip">Items: <span id="video-queue-length">{{ video_queue_length }}</span></span>
</h3> </div>
<div class="bg-gray-800 rounded-xl p-4 max-h-64 overflow-y-auto"> <div id="video-queue-container" style="max-height: 16rem; overflow-y: auto;">
<div id="video-queue-container"> {% if video_queue %}
{% if video_queue %} <div class="space-y-2">
<div class="space-y-2"> {% for file_path in video_queue %}
{% for file_path in video_queue %} <div class="md-card-outlined" style="padding: 0.5rem 0.75rem;">
<div class="flex items-center space-x-2 text-sm"> <span class="status-dot" style="background: #f59e0b;"></span>
<div class="w-2 h-2 bg-yellow-500 rounded-full"></div> <span style="margin-left: 0.5rem;">{{ file_path.split('/')[-1] }}</span>
<span class="text-gray-300">{{ file_path.split('/')[-1] }}</span> </div>
</div> {% endfor %}
{% endfor %} </div>
</div> {% else %}
{% else %} <div style="color: var(--md-sys-color-on-surface-variant); text-align: center; padding: 1rem;">
<p class="text-gray-400 text-sm">No files in queue</p> No files in queue
{% endif %} </div>
</div> {% endif %}
</div> </div>
</div> </div>
<!-- Statistics -->
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="bg-gray-800 rounded-xl p-4 flex flex-col items-center"> <div class="md-card-outlined" style="padding: 1rem; text-align: center;">
<div id="video-total-jobs" class="text-3xl font-extrabold text-sky-400">{{ video_stats.total_jobs or 0 }}</div> <div id="video-total-saved" class="md-title-large" style="font-weight: 700;">
<div class="text-base text-gray-400">Total Jobs</div> {{ video_stats.total_saved|format_bytes }}
</div>
<div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Space Saved</div>
</div> </div>
<div class="bg-gray-800 rounded-xl p-4 flex flex-col items-center"> <div class="md-card-outlined" style="padding: 1rem; text-align: center;">
<div id="video-total-saved" class="text-3xl font-extrabold text-green-400">{{ video_stats.total_saved|format_bytes }}</div> <div class="md-title-large" style="font-weight: 700;">
<div class="text-base text-gray-400">Space Saved</div> {{ (video_stats.total_original_size or 0)|format_bytes }}
</div>
<div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Original Size</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Audio Encoder Column --> <div class="md-card md-card-elevated" style="padding: 1.5rem;">
<div class="bg-gray-900 rounded-2xl modern-shadow p-8 transition-all hover:scale-[1.01]"> <div class="flex items-center justify-between mb-4">
<div class="flex items-center justify-between mb-6"> <h2 class="md-title-large" style="margin: 0;">
<h2 class="text-2xl font-bold text-white flex items-center"> <i class="fas fa-music" style="color: var(--md-sys-color-tertiary);"></i>
<svg class="w-7 h-7 mr-2 text-fuchsia-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
Audio Encoder Audio Encoder
</h2> </h2>
<a href="{{ url_for('reports', encoder_type='audio') }}" class="text-fuchsia-400 hover:text-white text-base font-semibold transition-all">View Reports</a> <a href="{{ url_for('reports', encoder_type='audio') }}" class="md-button md-button-text">Job Reports</a>
</div> </div>
<!-- Current Job Status --> <div class="md-card-outlined" style="padding: 1rem; margin-bottom: 1rem;">
<div class="mb-6"> <div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white mb-3">Current Job</h3> <div>
<div class="bg-gray-800 rounded-xl p-4"> <div class="md-title-large" style="font-size: 1rem;">Current Job</div>
<div id="audio-status"> <div id="audio-status" style="margin-top: 0.5rem;">
{% if current_audio_job %} {% if current_audio_job %}
<div class="flex items-center space-x-2"> <span class="md-chip"><span class="status-dot green"></span> Processing: {{ current_audio_job.file_path.split('/')[-1] }}</span>
<div class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div> <span style="margin-left: 0.5rem; color: var(--md-sys-color-on-surface-variant); font-size: 0.875rem;">
<span class="text-sm">Processing: {{ current_audio_job.file_path.split('/')[-1] }}</span> ({{ current_audio_job_duration|format_duration }})
<span class="text-xs text-gray-400">({{ current_audio_job_duration|format_duration }})</span> </span>
</div> {% else %}
{% else %} <span class="md-chip"><span class="status-dot gray"></span> Idle</span>
<div class="flex items-center space-x-2"> {% endif %}
<div class="w-3 h-3 bg-gray-500 rounded-full"></div> </div>
<span class="text-sm">Idle</span> </div>
</div> <div class="md-card-outlined" style="padding: 0.75rem 1rem; text-align: center;">
{% endif %} <div id="audio-total-jobs" class="md-title-large" style="font-weight: 700;">{{ audio_stats.total_jobs or 0 }}</div>
<div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Total Jobs</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Queue --> <div class="md-card-outlined" style="padding: 1rem; margin-bottom: 1rem;">
<div class="mb-6"> <div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-white mb-3"> <div class="md-title-large" style="font-size: 1rem;">Queue</div>
Queue (<span id="audio-queue-length">{{ audio_queue_length }}</span>) <span class="md-chip">Items: <span id="audio-queue-length">{{ audio_queue_length }}</span></span>
</h3> </div>
<div class="bg-gray-800 rounded-xl p-4 max-h-64 overflow-y-auto"> <div id="audio-queue-container" style="max-height: 16rem; overflow-y: auto;">
<div id="audio-queue-container"> {% if audio_queue %}
{% if audio_queue %} <div class="space-y-2">
<div class="space-y-2"> {% for file_path in audio_queue %}
{% for file_path in audio_queue %} <div class="md-card-outlined" style="padding: 0.5rem 0.75rem;">
<div class="flex items-center space-x-2 text-sm"> <span class="status-dot" style="background: #f59e0b;"></span>
<div class="w-2 h-2 bg-yellow-500 rounded-full"></div> <span style="margin-left: 0.5rem;">{{ file_path.split('/')[-1] }}</span>
<span class="text-gray-300">{{ file_path.split('/')[-1] }}</span> </div>
</div> {% endfor %}
{% endfor %} </div>
</div> {% else %}
{% else %} <div style="color: var(--md-sys-color-on-surface-variant); text-align: center; padding: 1rem;">
<p class="text-gray-400 text-sm">No files in queue</p> No files in queue
{% endif %} </div>
</div> {% endif %}
</div> </div>
</div> </div>
<!-- Statistics -->
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="bg-gray-800 rounded-xl p-4 flex flex-col items-center"> <div class="md-card-outlined" style="padding: 1rem; text-align: center;">
<div id="audio-total-jobs" class="text-3xl font-extrabold text-fuchsia-400">{{ audio_stats.total_jobs or 0 }}</div> <div id="audio-total-saved" class="md-title-large" style="font-weight: 700;">
<div class="text-base text-gray-400">Total Jobs</div> {{ audio_stats.total_saved|format_bytes }}
</div>
<div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Space Saved</div>
</div> </div>
<div class="bg-gray-800 rounded-xl p-4 flex flex-col items-center"> <div class="md-card-outlined" style="padding: 1rem; text-align: center;">
<div id="audio-total-saved" class="text-3xl font-extrabold text-green-400">{{ audio_stats.total_saved|format_bytes }}</div> <div class="md-title-large" style="font-weight: 700;">
<div class="text-base text-gray-400">Space Saved</div> {{ (audio_stats.total_original_size or 0)|format_bytes }}
</div>
<div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Original Size</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Overall Statistics --> <div class="md-card md-card-elevated" style="padding: 1.5rem; margin-top: 2rem;">
<div class="mt-8 bg-gradient-to-r from-fuchsia-900 via-gray-900 to-sky-900 rounded-2xl modern-shadow p-8"> <div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold text-white mb-4">Overall Statistics</h2> <h2 class="md-title-large" style="margin: 0;">Overall Performance</h2>
<a href="{{ url_for('reports_dashboard') }}" class="md-button md-button-text">Full Analytics</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4"> <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-gray-800 rounded-xl p-4 text-center"> <div class="md-card-outlined" style="padding: 1rem; text-align: center;">
<div id="overall-total-jobs" class="text-3xl font-extrabold text-sky-400">{{ (video_stats.total_jobs or 0) + (audio_stats.total_jobs or 0) }}</div> <div id="overall-total-jobs" class="md-title-large" style="font-weight: 700;">
<div class="text-base text-gray-400">Total Jobs Processed</div> {{ (video_stats.total_jobs or 0) + (audio_stats.total_jobs or 0) }}
</div>
<div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Total Jobs</div>
</div> </div>
<div class="bg-gray-800 rounded-xl p-4 text-center"> <div class="md-card-outlined" style="padding: 1rem; text-align: center;">
<div id="overall-total-saved" class="text-3xl font-extrabold text-green-400">{{ ((video_stats.total_saved or 0) + (audio_stats.total_saved or 0))|format_bytes }}</div> <div id="overall-total-saved" class="md-title-large" style="font-weight: 700;">
<div class="text-base text-gray-400">Total Space Saved</div> {{ ((video_stats.total_saved or 0) + (audio_stats.total_saved or 0))|format_bytes }}
</div>
<div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Space Saved</div>
</div> </div>
<div class="bg-gray-800 rounded-xl p-4 text-center"> <div class="md-card-outlined" style="padding: 1rem; text-align: center;">
<div id="overall-original-size" class="text-3xl font-extrabold text-yellow-400">{{ ((video_stats.total_original_size or 0) + (audio_stats.total_original_size or 0))|format_bytes }}</div> <div id="overall-original-size" class="md-title-large" style="font-weight: 700;">
<div class="text-base text-gray-400">Original Size</div> {{ ((video_stats.total_original_size or 0) + (audio_stats.total_original_size or 0))|format_bytes }}
</div>
<div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Original Size</div>
</div> </div>
<div class="bg-gray-800 rounded-xl p-4 text-center"> <div class="md-card-outlined" style="padding: 1rem; text-align: center;">
<div id="overall-encoded-size" class="text-3xl font-extrabold text-fuchsia-400">{{ ((video_stats.total_encoded_size or 0) + (audio_stats.total_encoded_size or 0))|format_bytes }}</div> <div id="overall-encoded-size" class="md-title-large" style="font-weight: 700;">
<div class="text-base text-gray-400">Encoded Size</div> {{ ((video_stats.total_encoded_size or 0) + (audio_stats.total_encoded_size or 0))|format_bytes }}
</div>
<div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">Encoded Size</div>
</div> </div>
</div> </div>
</div> </div>
+96 -164
View File
@@ -3,141 +3,112 @@
{% block title %}{{ encoder_type.title() }} Reports - Encoder{% endblock %} {% block title %}{{ encoder_type.title() }} Reports - Encoder{% endblock %}
{% block content %} {% block content %}
<div class="px-4 sm:px-0 max-w-full mx-auto"> <div class="fade-in">
<div class="mb-8"> <div class="flex items-center justify-between mb-6">
<div class="flex items-center justify-between"> <div>
<div> <h1 class="md-display-medium mb-2">{{ encoder_type.title() }} Encoder Reports</h1>
<h1 class="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-fuchsia-400 via-sky-400 to-cyan-400 mb-2 tracking-tight">{{ encoder_type.title() }} Encoder Reports</h1> <p style="color: var(--md-sys-color-on-surface-variant);">Job history and statistics for {{ encoder_type }} encoding.</p>
<p class="text-gray-300 text-lg">Job history and statistics for {{ encoder_type }} encoding</p> </div>
</div> <div class="flex gap-2">
<a href="{{ url_for('index') }}" class="bg-gradient-to-r from-sky-600 to-fuchsia-600 hover:from-sky-500 hover:to-fuchsia-500 text-white px-6 py-2 rounded-full text-base shadow-lg font-semibold transition-all"> <a href="{{ url_for('reports_dashboard') }}" class="md-button md-button-text">
← Back to Dashboard <i class="fas fa-chart-line"></i> Reports Dashboard
</a>
<a href="{{ url_for('index') }}" class="md-button md-button-outlined">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a> </a>
</div> </div>
</div> </div>
<div class="bg-gray-900 rounded-2xl modern-shadow overflow-hidden px-2 sm:px-6 md:px-12">
<div class="px-8 py-6 border-b border-gray-800"> <div class="md-card md-card-elevated" style="padding: 1.5rem;">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div> <div>
<h2 class="text-2xl font-bold text-white">Job Reports</h2> <h2 class="md-title-large" style="margin: 0;">Job Reports</h2>
<p class="text-base text-gray-400">Total: {{ total }} jobs</p> <p style="color: var(--md-sys-color-on-surface-variant);">Total: {{ total }} jobs</p>
</div>
</div> </div>
<!-- Search Form -->
<form method="GET" class="mb-4">
<div class="flex space-x-2">
<input type="text" name="search" value="{{ search_term or '' }}" placeholder="Search files or error messages..."
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent">
<button type="submit" class="bg-sky-600 hover:bg-sky-700 text-white px-4 py-2 rounded-lg text-base font-semibold transition-all">
Search
</button>
{% if search_term %}
<a href="{{ url_for('reports', encoder_type=encoder_type) }}" class="bg-gray-700 hover:bg-gray-800 text-white px-4 py-2 rounded-lg text-base font-semibold transition-all">
Clear
</a>
{% endif %}
</div>
</form>
</div> </div>
<form method="GET" class="grid grid-cols-1 md:grid-cols-3 gap-4" style="margin-bottom: 1.5rem;">
<div class="md-text-field" style="grid-column: span 2;">
<input type="text" name="search" value="{{ search_term or '' }}" placeholder=" " id="report-search">
<label for="report-search">Search files or error messages...</label>
</div>
<div class="flex items-center gap-2">
<button type="submit" class="md-button md-button-filled">
<i class="fas fa-magnifying-glass"></i> Search
</button>
{% if search_term %}
<a href="{{ url_for('reports', encoder_type=encoder_type) }}" class="md-button md-button-text">Clear</a>
{% endif %}
</div>
</form>
{% if reports %} {% if reports %}
<div class="overflow-x-auto"> <div style="overflow-x: auto;">
<table class="w-full min-w-full divide-y divide-gray-800 rounded-xl modern-shadow"> <table style="width: 100%; border-collapse: collapse;">
<thead class="bg-gray-800"> <thead>
<tr> <tr style="text-align: left; border-bottom: 1px solid var(--md-sys-color-outline-variant);">
<th class="px-6 py-3 text-left text-xs font-bold text-sky-300 uppercase tracking-wider">File</th> <th style="padding: 0.75rem;">File</th>
<th class="px-6 py-3 text-left text-xs font-bold text-sky-300 uppercase tracking-wider">Status</th> <th style="padding: 0.75rem;">Status</th>
<th class="px-6 py-3 text-left text-xs font-bold text-sky-300 uppercase tracking-wider">Original Size</th> <th style="padding: 0.75rem;">Original Size</th>
<th class="px-6 py-3 text-left text-xs font-bold text-sky-300 uppercase tracking-wider">Encoded Size</th> <th style="padding: 0.75rem;">Encoded Size</th>
<th class="px-6 py-3 text-left text-xs font-bold text-sky-300 uppercase tracking-wider">Space Saved</th> <th style="padding: 0.75rem;">Space Saved</th>
<th class="px-6 py-3 text-left text-xs font-bold text-sky-300 uppercase tracking-wider">Format</th> <th style="padding: 0.75rem;">Format</th>
<th class="px-6 py-3 text-left text-xs font-bold text-sky-300 uppercase tracking-wider">Processing Time</th> <th style="padding: 0.75rem;">Processing Time</th>
<th class="px-6 py-3 text-left text-xs font-bold text-sky-300 uppercase tracking-wider">Date</th> <th style="padding: 0.75rem;">Date</th>
<th class="px-6 py-3 text-left text-xs font-bold text-sky-300 uppercase tracking-wider">Actions</th> <th style="padding: 0.75rem;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-gray-900 divide-y divide-gray-800"> <tbody>
{% for report in reports %} {% for report in reports %}
<tr class="hover:bg-gray-800 transition-all"> <tr style="border-bottom: 1px solid var(--md-sys-color-outline-variant);">
<td class="px-6 py-4 whitespace-nowrap"> <td style="padding: 0.75rem;">
<div class="text-base text-white font-semibold">{{ report.file_path.split('/')[-1] }}</div> <div style="font-weight: 600;">{{ report.file_path.split('/')[-1] }}</div>
<div class="text-xs text-gray-400">{{ report.file_path }}</div> <div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">{{ report.file_path }}</div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td style="padding: 0.75rem;">
{% if report.status == 'success' %} {% if report.status == 'success' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-900 text-green-200"> <span class="md-chip"><span class="status-dot green"></span> Success</span>
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"/>
</svg>
Success
</span>
{% elif report.status == 'failed' %} {% elif report.status == 'failed' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-900 text-red-200"> <span class="md-chip"><span class="status-dot red"></span> Failed</span>
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"/>
</svg>
Failed
</span>
{% elif report.status == 'skipped' %} {% elif report.status == 'skipped' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-sky-900 text-sky-200"> <span class="md-chip"><span class="status-dot gray"></span> Skipped</span>
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="2" fill="none" />
<path fill-rule="evenodd" d="M6 10a4 4 0 118 0 4 4 0 01-8 0z" fill="currentColor" />
</svg>
Skipped
</span>
{% else %} {% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-yellow-900 text-yellow-200"> <span class="md-chip"><span class="status-dot" style="background: #f59e0b;"></span> Processing</span>
<svg class="w-3 h-3 mr-1 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing
</span>
{% endif %} {% endif %}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-base text-gray-300"> <td style="padding: 0.75rem;">{{ report.original_size|format_bytes }}</td>
{{ report.original_size|format_bytes }} <td style="padding: 0.75rem;">{{ report.encoded_size|format_bytes }}</td>
</td> <td style="padding: 0.75rem;">
<td class="px-6 py-4 whitespace-nowrap text-base text-gray-300">
{{ report.encoded_size|format_bytes }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-base">
{% if report.size_saved > 0 %} {% if report.size_saved > 0 %}
<span class="text-green-400 font-semibold">{{ report.size_saved|format_bytes }}</span> <span style="color: #10b981; font-weight: 600;">{{ report.size_saved|format_bytes }}</span>
{% elif report.size_saved < 0 %} {% elif report.size_saved < 0 %}
<span class="text-red-400 font-semibold">{{ report.size_saved|format_bytes }}</span> <span style="color: #ef4444; font-weight: 600;">{{ report.size_saved|format_bytes }}</span>
{% else %} {% else %}
<span class="text-gray-400 font-semibold">{{ report.size_saved|format_bytes }}</span> <span style="color: var(--md-sys-color-on-surface-variant); font-weight: 600;">{{ report.size_saved|format_bytes }}</span>
{% endif %} {% endif %}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-base text-gray-300"> <td style="padding: 0.75rem;">
<span class="text-gray-400">{{ report.original_format }}</span> <span style="color: var(--md-sys-color-on-surface-variant);">{{ report.original_format }}</span>
<span class="text-gray-500"></span> <span></span>
<span class="text-white">{{ report.encoded_format }}</span> <span>{{ report.encoded_format }}</span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-base text-gray-300"> <td style="padding: 0.75rem;">{{ report.processing_time|format_duration }}</td>
{{ report.processing_time|format_duration }} <td style="padding: 0.75rem;">
</td>
<td class="px-6 py-4 whitespace-nowrap text-base text-gray-300">
<div>{{ report.created_at.strftime('%Y-%m-%d') }}</div> <div>{{ report.created_at.strftime('%Y-%m-%d') }}</div>
<div class="text-xs text-gray-400">{{ report.created_at.strftime('%H:%M:%S') }}</div> <div style="font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant);">{{ report.created_at.strftime('%H:%M:%S') }}</div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-base text-gray-300"> <td style="padding: 0.75rem;">
{% if report.status == 'failed' %} {% if report.status == 'failed' %}
<button onclick="requeueJob({{ report.id }})" class="bg-gradient-to-r from-yellow-500 to-yellow-700 hover:from-yellow-400 hover:to-yellow-600 text-white px-3 py-1 rounded-full text-xs font-semibold transition-all"> <button onclick="requeueJob({{ report.id }})" class="md-button md-button-text">Requeue</button>
Requeue
</button>
{% else %} {% else %}
<span class="text-gray-500">-</span> <span style="color: var(--md-sys-color-on-surface-variant);">-</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% if report.status == 'failed' and report.error_message %} {% if report.status == 'failed' and report.error_message %}
<tr class="bg-red-900 bg-opacity-20"> <tr>
<td colspan="9" class="px-6 py-2"> <td colspan="9" style="padding: 0.75rem; color: var(--md-sys-color-error);">
<div class="text-base text-red-300"> <strong>Error:</strong> {{ report.error_message }}
<span class="font-semibold">Error:</span> {{ report.error_message }}
</div>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
@@ -145,71 +116,32 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination -->
{% if total_pages > 1 %} {% if total_pages > 1 %}
<div class="bg-gray-700 px-4 py-3 flex items-center justify-between border-t border-gray-600 sm:px-6"> <div class="flex items-center justify-between" style="margin-top: 1.5rem;">
<div class="flex-1 flex justify-between sm:hidden"> <div style="color: var(--md-sys-color-on-surface-variant); font-size: 0.875rem;">
{% if page > 1 %} Showing {{ (page-1)*20 + 1 }} to {{ min(page*20, total) }} of {{ total }} results
<a href="{{ url_for('reports', encoder_type=encoder_type, page=page-1) }}" class="relative inline-flex items-center px-4 py-2 border border-gray-600 text-sm font-medium rounded-md text-gray-300 bg-gray-800 hover:bg-gray-700">
Previous
</a>
{% endif %}
{% if page < total_pages %}
<a href="{{ url_for('reports', encoder_type=encoder_type, page=page+1) }}" class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-600 text-sm font-medium rounded-md text-gray-300 bg-gray-800 hover:bg-gray-700">
Next
</a>
{% endif %}
</div> </div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> <div class="flex items-center gap-2">
<div> {% if page > 1 %}
<p class="text-sm text-gray-300"> <a href="{{ url_for('reports', encoder_type=encoder_type, page=page-1) }}" class="md-button md-button-text">Previous</a>
Showing <span class="font-medium">{{ (page-1)*20 + 1 }}</span> to <span class="font-medium">{{ min(page*20, total) }}</span> of <span class="font-medium">{{ total }}</span> results {% endif %}
</p> {% for p in range(max(1, page - 2), min(total_pages, page + 2) + 1) %}
</div> {% if p == page %}
<div> <span class="md-chip">{{ p }}</span>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"> {% else %}
{% if page > 1 %} <a href="{{ url_for('reports', encoder_type=encoder_type, page=p) }}" class="md-button md-button-text">{{ p }}</a>
<a href="{{ url_for('reports', encoder_type=encoder_type, page=page-1) }}" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-600 bg-gray-800 text-sm font-medium text-gray-300 hover:bg-gray-700"> {% endif %}
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> {% endfor %}
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"/> {% if page < total_pages %}
</svg> <a href="{{ url_for('reports', encoder_type=encoder_type, page=page+1) }}" class="md-button md-button-text">Next</a>
</a> {% endif %}
{% endif %}
{% set start_page = max(1, page - 2) %}
{% set end_page = min(total_pages, page + 2) %}
{% for p in range(start_page, end_page + 1) %}
{% if p == page %}
<span class="relative inline-flex items-center px-4 py-2 border border-gray-600 bg-gray-600 text-sm font-medium text-white">
{{ p }}
</span>
{% else %}
<a href="{{ url_for('reports', encoder_type=encoder_type, page=p) }}" class="relative inline-flex items-center px-4 py-2 border border-gray-600 bg-gray-800 text-sm font-medium text-gray-300 hover:bg-gray-700">
{{ p }}
</a>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for('reports', encoder_type=encoder_type, page=page+1) }}" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-600 bg-gray-800 text-sm font-medium text-gray-300 hover:bg-gray-700">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"/>
</svg>
</a>
{% endif %}
</nav>
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% else %} {% else %}
<div class="px-6 py-12 text-center"> <div class="md-card-outlined" style="padding: 2rem; text-align: center;">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <p style="color: var(--md-sys-color-on-surface-variant);">No encoding jobs have been completed yet.</p>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-300">No reports found</h3>
<p class="mt-1 text-sm text-gray-400">No encoding jobs have been completed yet.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
+306
View File
@@ -0,0 +1,306 @@
{% extends "base.html" %}
{% block title %}Reports Dashboard - Encoder{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
{% endblock %}
{% block content %}
<div class="fade-in">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="md-display-medium mb-2">Reports Dashboard</h1>
<p style="color: var(--md-sys-color-on-surface-variant);">Deep dive into savings, throughput, and encoding reliability.</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('reports', encoder_type='video') }}" class="md-button md-button-text">
<i class="fas fa-video"></i> Video Jobs
</a>
<a href="{{ url_for('reports', encoder_type='audio') }}" class="md-button md-button-text">
<i class="fas fa-music"></i> Audio Jobs
</a>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="md-card md-card-elevated" style="padding: 1rem;">
<div class="md-title-large">{{ summary.total_jobs or 0 }}</div>
<div style="color: var(--md-sys-color-on-surface-variant); font-size: 0.875rem;">Total Jobs</div>
</div>
<div class="md-card md-card-elevated" style="padding: 1rem;">
<div class="md-title-large">{{ summary.total_saved|format_bytes }}</div>
<div style="color: var(--md-sys-color-on-surface-variant); font-size: 0.875rem;">Total Saved</div>
</div>
<div class="md-card md-card-elevated" style="padding: 1rem;">
<div class="md-title-large">{{ summary.avg_processing_time|format_duration }}</div>
<div style="color: var(--md-sys-color-on-surface-variant); font-size: 0.875rem;">Avg Processing Time</div>
</div>
<div class="md-card md-card-elevated" style="padding: 1rem;">
<div class="md-title-large">{{ (savings_ratio * 100) | round(1) }}%</div>
<div style="color: var(--md-sys-color-on-surface-variant); font-size: 0.875rem;">Savings Ratio</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8" style="margin-top: 2rem;">
<div class="md-card md-card-elevated" style="padding: 1.5rem;">
<h2 class="md-title-large">Storage Saved Over Time</h2>
<canvas id="savingsOverTimeChart" height="120"></canvas>
</div>
<div class="md-card md-card-elevated" style="padding: 1.5rem;">
<h2 class="md-title-large">Jobs Over Time</h2>
<canvas id="jobsOverTimeChart" height="120"></canvas>
</div>
<div class="md-card md-card-elevated" style="padding: 1.5rem;">
<h2 class="md-title-large">Status Breakdown</h2>
<canvas id="statusBreakdownChart" height="180"></canvas>
</div>
<div class="md-card md-card-elevated" style="padding: 1.5rem;">
<h2 class="md-title-large">Average Processing Time</h2>
<canvas id="processingTimeChart" height="180"></canvas>
</div>
<div class="md-card md-card-elevated" style="padding: 1.5rem;">
<h2 class="md-title-large">Savings by Encoder</h2>
<canvas id="encoderSavingsChart" height="180"></canvas>
</div>
<div class="md-card md-card-elevated" style="padding: 1.5rem;">
<h2 class="md-title-large">Top Folders by Savings</h2>
<canvas id="topFoldersChart" height="140"></canvas>
</div>
<div class="md-card md-card-elevated" style="padding: 1.5rem;">
<h2 class="md-title-large">Format Breakdown</h2>
<canvas id="formatBreakdownChart" height="140"></canvas>
</div>
<div class="md-card md-card-elevated" style="padding: 1.5rem;">
<h2 class="md-title-large">Top Error Messages</h2>
{% if top_errors %}
<div class="space-y-2">
{% for error in top_errors %}
<div class="md-card-outlined" style="padding: 0.75rem;">
<div style="font-weight: 600;">{{ error.total_errors }}x</div>
<div style="color: var(--md-sys-color-on-surface-variant); font-size: 0.875rem;">{{ error.error_message }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="color: var(--md-sys-color-on-surface-variant);">No errors recorded yet.</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const savingsOverTime = {{ savings_over_time|tojson }};
const jobsOverTime = {{ jobs_over_time|tojson }};
const processingTimeOverTime = {{ processing_time_over_time|tojson }};
const savingsByEncoder = {{ savings_by_encoder|tojson }};
const formatBreakdown = {{ format_breakdown|tojson }};
const topFolders = {{ top_folders|tojson }};
const summary = {{ summary|tojson }};
const savingsLabels = savingsOverTime.map(item => item.day);
const savingsValues = savingsOverTime.map(item => item.total_saved || 0);
const jobsLabels = jobsOverTime.map(item => item.day);
const jobsTotal = jobsOverTime.map(item => item.total_jobs || 0);
const jobsSuccess = jobsOverTime.map(item => item.success_jobs || 0);
const jobsFailed = jobsOverTime.map(item => item.failed_jobs || 0);
const jobsSkipped = jobsOverTime.map(item => item.skipped_jobs || 0);
const processingLabels = processingTimeOverTime.map(item => item.day);
const processingValues = processingTimeOverTime.map(item => item.avg_processing_time || 0);
const encoderLabels = savingsByEncoder.map(item => item.encoder_type || 'unknown');
const encoderSavings = savingsByEncoder.map(item => item.total_saved || 0);
const folderLabels = topFolders.map(item => item.folder);
const folderSavings = topFolders.map(item => item.total_saved || 0);
const formatLabels = formatBreakdown.map(item => `${item.original_format}${item.encoded_format}`);
const formatCounts = formatBreakdown.map(item => item.total_jobs || 0);
function formatBytes(bytes) {
if (bytes == null || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = Math.abs(bytes);
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const sign = bytes < 0 ? '-' : '';
return sign + size.toFixed(1) + ' ' + units[unitIndex];
}
new Chart(document.getElementById('savingsOverTimeChart'), {
type: 'line',
data: {
labels: savingsLabels,
datasets: [{
label: 'Storage Saved',
data: savingsValues,
borderColor: '#4CDADB',
backgroundColor: 'rgba(76, 218, 219, 0.2)',
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
plugins: {
tooltip: {
callbacks: {
label: (ctx) => `Saved: ${formatBytes(ctx.parsed.y)}`
}
}
},
scales: {
y: {
ticks: {
callback: (value) => formatBytes(value)
}
}
}
}
});
new Chart(document.getElementById('jobsOverTimeChart'), {
type: 'bar',
data: {
labels: jobsLabels,
datasets: [
{ label: 'Total', data: jobsTotal, backgroundColor: 'rgba(76, 218, 219, 0.4)' },
{ label: 'Success', data: jobsSuccess, backgroundColor: 'rgba(16, 185, 129, 0.6)' },
{ label: 'Failed', data: jobsFailed, backgroundColor: 'rgba(239, 68, 68, 0.6)' },
{ label: 'Skipped', data: jobsSkipped, backgroundColor: 'rgba(107, 114, 128, 0.6)' }
]
},
options: {
responsive: true,
scales: {
x: { stacked: true },
y: { stacked: true }
}
}
});
new Chart(document.getElementById('statusBreakdownChart'), {
type: 'doughnut',
data: {
labels: ['Success', 'Failed', 'Skipped'],
datasets: [{
data: [
summary.success_jobs || 0,
summary.failed_jobs || 0,
summary.skipped_jobs || 0
],
backgroundColor: ['#10b981', '#ef4444', '#6b7280']
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' }
}
}
});
new Chart(document.getElementById('processingTimeChart'), {
type: 'line',
data: {
labels: processingLabels,
datasets: [{
label: 'Avg Processing Time (s)',
data: processingValues,
borderColor: '#A5C9E2',
backgroundColor: 'rgba(165, 201, 226, 0.2)',
fill: true,
tension: 0.3
}]
},
options: {
responsive: true
}
});
new Chart(document.getElementById('encoderSavingsChart'), {
type: 'bar',
data: {
labels: encoderLabels,
datasets: [{
label: 'Total Saved',
data: encoderSavings,
backgroundColor: 'rgba(76, 218, 219, 0.5)'
}]
},
options: {
responsive: true,
plugins: {
tooltip: {
callbacks: {
label: (ctx) => `Saved: ${formatBytes(ctx.parsed.y)}`
}
}
},
scales: {
y: {
ticks: {
callback: (value) => formatBytes(value)
}
}
}
}
});
new Chart(document.getElementById('topFoldersChart'), {
type: 'bar',
data: {
labels: folderLabels,
datasets: [{
label: 'Total Saved',
data: folderSavings,
backgroundColor: 'rgba(76, 218, 219, 0.5)'
}]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: {
tooltip: {
callbacks: {
label: (ctx) => `Saved: ${formatBytes(ctx.parsed.x)}`
}
}
},
scales: {
x: {
ticks: {
callback: (value) => formatBytes(value)
}
}
}
}
});
new Chart(document.getElementById('formatBreakdownChart'), {
type: 'bar',
data: {
labels: formatLabels,
datasets: [{
label: 'Jobs',
data: formatCounts,
backgroundColor: 'rgba(165, 201, 226, 0.6)'
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false }
}
}
});
</script>
{% endblock %}