HuggingVideo / app.py
root39058's picture
Update app.py
0724ef3 verified
Raw
History Blame Contribute Delete
40.2 kB
# huggingvideo.py - Полный видеохостинг на Hugging Face Spaces
import os, json, uuid, hashlib, sqlite3, subprocess, shutil, time, threading, re
from datetime import datetime
from pathlib import Path
from functools import wraps
from flask import Flask, request, render_template_string, jsonify, send_file, abort, url_for, send_from_directory, stream_with_context, Response, redirect
from werkzeug.utils import secure_filename
import cv2
import numpy as np
BASE_DIR = "/data"
VIDEO_DIR = os.path.join(BASE_DIR, "videos")
THUMBNAIL_DIR = os.path.join(BASE_DIR, "thumbnails")
CACHE_DIR = os.path.join(BASE_DIR, "cache")
METADATA_DIR = os.path.join(BASE_DIR, "metadata")
DB_PATH = os.path.join(BASE_DIR, "huggingvideo.db")
for dir_path in [VIDEO_DIR, THUMBNAIL_DIR, CACHE_DIR, METADATA_DIR]:
os.makedirs(dir_path, exist_ok=True)
MAX_FILE_SIZE = 500 * 1024 * 1024
ALLOWED_EXTENSIONS = {'mp4', 'webm', 'avi', 'mov', 'mkv', 'flv', 'wmv', 'm4v', '3gp'}
CHUNK_SIZE = 1024 * 1024
MAX_VIDEO_DURATION = 3600
VERSION = "1.0.0"
app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(24)
app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS videos (
id TEXT PRIMARY KEY, title TEXT, description TEXT, filename TEXT,
size INTEGER, duration INTEGER, width INTEGER, height INTEGER,
views INTEGER DEFAULT 0, likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0,
upload_date TIMESTAMP, last_viewed TIMESTAMP, status TEXT DEFAULT 'processing',
format TEXT, video_url TEXT, thumbnail_url TEXT, hls_url TEXT,
category TEXT, tags TEXT, is_public INTEGER DEFAULT 1
)''')
c.execute('''CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, username TEXT UNIQUE, password_hash TEXT, created_at TIMESTAMP
)''')
c.execute('''CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY, name TEXT, user_id TEXT, created_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
)''')
c.execute('''CREATE TABLE IF NOT EXISTS playlist_videos (
playlist_id TEXT, video_id TEXT, position INTEGER,
FOREIGN KEY (playlist_id) REFERENCES playlists (id),
FOREIGN KEY (video_id) REFERENCES videos (id)
)''')
c.execute('''CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY, video_id TEXT, user_id TEXT, text TEXT,
created_at TIMESTAMP, likes INTEGER DEFAULT 0,
FOREIGN KEY (video_id) REFERENCES videos (id)
)''')
conn.commit()
conn.close()
init_db()
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_video_info(filepath):
try:
cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filepath]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
data = json.loads(result.stdout)
info = {'duration': 0, 'width': 0, 'height': 0, 'format': '', 'size': os.path.getsize(filepath)}
if 'format' in data:
info['duration'] = int(float(data['format'].get('duration', 0)))
info['format'] = data['format'].get('format_name', '')
for stream in data.get('streams', []):
if stream.get('codec_type') == 'video':
info['width'] = int(stream.get('width', 0))
info['height'] = int(stream.get('height', 0))
break
return info
except:
return {'duration': 0, 'width': 0, 'height': 0, 'format': 'unknown', 'size': 0}
def generate_thumbnail(video_path, thumbnail_path, time_sec=5):
try:
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS)
frame_number = int(time_sec * fps)
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
ret, frame = cap.read()
cap.release()
if ret:
height, width = frame.shape[:2]
scale = min(640 / width, 360 / height)
new_width = int(width * scale)
new_height = int(height * scale)
frame = cv2.resize(frame, (new_width, new_height))
cv2.imwrite(thumbnail_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
return True
except:
pass
return False
def create_hls(video_path, output_dir):
try:
os.makedirs(output_dir, exist_ok=True)
playlist_path = os.path.join(output_dir, 'playlist.m3u8')
cmd = ['ffmpeg', '-i', video_path, '-c:v', 'libx264', '-crf', '23', '-preset', 'medium',
'-c:a', 'aac', '-b:a', '128k', '-f', 'hls', '-hls_time', '10',
'-hls_list_size', '0', '-hls_segment_filename', os.path.join(output_dir, 'segment_%03d.ts'),
playlist_path]
subprocess.run(cmd, check=True, capture_output=True, timeout=300)
return playlist_path
except:
return None
def compress_video(input_path, output_path, quality='medium'):
qualities = {
'low': ['-c:v', 'libx264', '-crf', '28', '-preset', 'fast'],
'medium': ['-c:v', 'libx264', '-crf', '23', '-preset', 'medium'],
'high': ['-c:v', 'libx264', '-crf', '18', '-preset', 'slow']
}
try:
cmd = ['ffmpeg', '-i', input_path, *qualities[quality], '-c:a', 'aac', '-b:a', '128k', output_path]
subprocess.run(cmd, check=True, capture_output=True, timeout=600)
return True
except:
return False
def get_dir_size(path):
total = 0
for entry in os.scandir(path):
if entry.is_file():
total += entry.stat().st_size
elif entry.is_dir():
total += get_dir_size(entry.path)
return total
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE)
@app.route('/api/videos', methods=['GET'])
def get_videos():
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 20))
offset = (page - 1) * per_page
category = request.args.get('category', '')
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
query = 'SELECT COUNT(*) FROM videos WHERE status = "ready"'
params = []
if category:
query += ' AND category = ?'
params.append(category)
c.execute(query, params)
total = c.fetchone()[0]
query = '''SELECT id, title, description, filename, size, duration, width, height,
views, likes, dislikes, upload_date, format, thumbnail_url, video_url, hls_url, category, tags
FROM videos WHERE status = "ready"'''
if category:
query += ' AND category = ?'
query += ' ORDER BY upload_date DESC LIMIT ? OFFSET ?'
params.extend([per_page, offset])
c.execute(query, params)
videos = []
for row in c.fetchall():
videos.append({
'id': row[0], 'title': row[1], 'description': row[2], 'filename': row[3],
'size': row[4], 'duration': row[5], 'width': row[6], 'height': row[7],
'views': row[8], 'likes': row[9], 'dislikes': row[10],
'upload_date': row[11], 'format': row[12],
'thumbnail_url': row[13] or f"/api/thumbnail/{row[0]}",
'video_url': row[14] or f"/api/video/{row[0]}",
'hls_url': row[15], 'category': row[16] or 'other', 'tags': row[17] or ''
})
conn.close()
return jsonify({'videos': videos, 'total': total, 'page': page, 'per_page': per_page,
'total_pages': (total + per_page - 1) // per_page})
@app.route('/api/video/<video_id>', methods=['GET'])
def get_video(video_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''SELECT id, title, description, filename, size, duration, width, height,
views, likes, dislikes, upload_date, format, thumbnail_url, video_url, hls_url,
status, category, tags
FROM videos WHERE id = ?''', (video_id,))
row = c.fetchone()
if not row:
conn.close()
return jsonify({'error': 'Video not found'}), 404
c.execute('UPDATE videos SET views = views + 1, last_viewed = CURRENT_TIMESTAMP WHERE id = ?', (video_id,))
conn.commit()
conn.close()
return jsonify({
'id': row[0], 'title': row[1], 'description': row[2], 'filename': row[3],
'size': row[4], 'duration': row[5], 'width': row[6], 'height': row[7],
'views': row[8], 'likes': row[9], 'dislikes': row[10],
'upload_date': row[11], 'format': row[12],
'thumbnail_url': row[13] or f"/api/thumbnail/{row[0]}",
'video_url': row[14] or f"/api/video/{row[0]}",
'hls_url': row[15], 'status': row[16], 'category': row[17] or 'other',
'tags': row[18] or ''
})
@app.route('/api/video/<video_id>/stream')
def stream_video(video_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT filename, video_url FROM videos WHERE id = ?', (video_id,))
row = c.fetchone()
conn.close()
if not row:
return jsonify({'error': 'Video not found'}), 404
video_path = os.path.join(VIDEO_DIR, row[0])
if not os.path.exists(video_path):
if row[1]:
return redirect(row[1])
return jsonify({'error': 'Video unavailable'}), 404
size = os.path.getsize(video_path)
range_header = request.headers.get('Range', None)
if not range_header:
return send_file(video_path, mimetype='video/mp4')
try:
byte_range = range_header.replace('bytes=', '').split('-')
byte_start = int(byte_range[0]) if byte_range[0] else 0
byte_end = int(byte_range[1]) if len(byte_range) > 1 and byte_range[1] else size - 1
byte_end = min(byte_end, size - 1)
except:
byte_start = 0
byte_end = size - 1
length = byte_end - byte_start + 1
def generate():
with open(video_path, 'rb') as f:
f.seek(byte_start)
remaining = length
while remaining > 0:
chunk = f.read(min(CHUNK_SIZE, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
response = Response(generate(), 206, mimetype='video/mp4')
response.headers.add('Content-Range', f'bytes {byte_start}-{byte_end}/{size}')
response.headers.add('Accept-Ranges', 'bytes')
response.headers.add('Content-Length', str(length))
response.headers.add('Cache-Control', 'public, max-age=86400')
return response
@app.route('/api/upload', methods=['POST'])
def upload_video():
if 'video' not in request.files:
return jsonify({'error': 'No file'}), 400
file = request.files['video']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if not allowed_file(file.filename):
return jsonify({'error': 'Unsupported format'}), 400
filename = secure_filename(file.filename)
video_id = str(uuid.uuid4())
safe_filename = f"{video_id}_{filename}"
filepath = os.path.join(VIDEO_DIR, safe_filename)
file.save(filepath)
info = get_video_info(filepath)
duration = info['duration']
if duration > MAX_VIDEO_DURATION:
os.remove(filepath)
return jsonify({'error': f'Video too long (max {MAX_VIDEO_DURATION}s)'}), 400
thumbnail_filename = f"{video_id}.jpg"
thumbnail_path = os.path.join(THUMBNAIL_DIR, thumbnail_filename)
generate_thumbnail(filepath, thumbnail_path, min(5, duration // 2))
title = request.form.get('title', filename)
description = request.form.get('description', '')
category = request.form.get('category', 'other')
tags = request.form.get('tags', '')
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''INSERT INTO videos
(id, title, description, filename, size, duration, width, height,
upload_date, format, status, thumbnail_url, category, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?, ?, ?, ?)''',
(video_id, title, description, safe_filename, info['size'], duration,
info['width'], info['height'], info['format'], 'processing',
f"/api/thumbnail/{video_id}", category, tags))
conn.commit()
conn.close()
thread = threading.Thread(target=process_video, args=(video_id, filepath))
thread.start()
return jsonify({'id': video_id, 'message': 'Video uploaded, processing started', 'status': 'processing'})
def process_video(video_id, filepath):
try:
compressed_filename = f"{video_id}_compressed.mp4"
compressed_path = os.path.join(VIDEO_DIR, compressed_filename)
compress_video(filepath, compressed_path, 'medium')
hls_dir = os.path.join(CACHE_DIR, video_id)
hls_path = create_hls(compressed_path, hls_dir)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
if hls_path:
c.execute('''UPDATE videos SET status = 'ready', video_url = ?, hls_url = ?
WHERE id = ?''',
(f"/api/video/{video_id}/stream", f"/api/hls/{video_id}/playlist.m3u8", video_id))
else:
c.execute('''UPDATE videos SET status = 'ready', video_url = ? WHERE id = ?''',
(f"/api/video/{video_id}/stream", video_id))
conn.commit()
conn.close()
except Exception as e:
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('UPDATE videos SET status = "error" WHERE id = ?', (video_id,))
conn.commit()
conn.close()
@app.route('/api/thumbnail/<video_id>')
def get_thumbnail(video_id):
thumbnail_path = os.path.join(THUMBNAIL_DIR, f"{video_id}.jpg")
if os.path.exists(thumbnail_path):
return send_file(thumbnail_path, mimetype='image/jpeg')
return '', 404
@app.route('/api/hls/<video_id>/<path:filename>')
def get_hls_segment(video_id, filename):
hls_dir = os.path.join(CACHE_DIR, video_id)
filepath = os.path.join(hls_dir, filename)
if os.path.exists(filepath):
return send_file(filepath, mimetype='application/vnd.apple.mpegurl' if filename.endswith('.m3u8') else 'video/MP2T')
return jsonify({'error': 'Segment not found'}), 404
@app.route('/api/search', methods=['GET'])
def search_videos():
query = request.args.get('q', '').strip()
if not query:
return get_videos()
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''SELECT id, title, description, filename, size, duration, width, height,
views, likes, dislikes, upload_date, format, thumbnail_url, video_url, hls_url, category, tags
FROM videos WHERE status = "ready"
AND (title LIKE ? OR description LIKE ? OR tags LIKE ?)
ORDER BY upload_date DESC LIMIT 50''',
(f'%{query}%', f'%{query}%', f'%{query}%'))
videos = []
for row in c.fetchall():
videos.append({
'id': row[0], 'title': row[1], 'description': row[2], 'filename': row[3],
'size': row[4], 'duration': row[5], 'width': row[6], 'height': row[7],
'views': row[8], 'likes': row[9], 'dislikes': row[10],
'upload_date': row[11], 'format': row[12],
'thumbnail_url': row[13] or f"/api/thumbnail/{row[0]}",
'video_url': row[14] or f"/api/video/{row[0]}",
'hls_url': row[15], 'category': row[16] or 'other', 'tags': row[17] or ''
})
conn.close()
return jsonify({'videos': videos, 'query': query})
@app.route('/api/like/<video_id>', methods=['POST'])
def like_video(video_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('UPDATE videos SET likes = likes + 1 WHERE id = ?', (video_id,))
conn.commit()
c.execute('SELECT likes FROM videos WHERE id = ?', (video_id,))
likes = c.fetchone()[0]
conn.close()
return jsonify({'likes': likes})
@app.route('/api/dislike/<video_id>', methods=['POST'])
def dislike_video(video_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('UPDATE videos SET dislikes = dislikes + 1 WHERE id = ?', (video_id,))
conn.commit()
c.execute('SELECT dislikes FROM videos WHERE id = ?', (video_id,))
dislikes = c.fetchone()[0]
conn.close()
return jsonify({'dislikes': dislikes})
@app.route('/api/comment/<video_id>', methods=['POST'])
def add_comment(video_id):
data = request.json
text = data.get('text', '').strip()
if not text:
return jsonify({'error': 'Comment cannot be empty'}), 400
comment_id = str(uuid.uuid4())
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''INSERT INTO comments (id, video_id, user_id, text, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)''',
(comment_id, video_id, 'anonymous', text))
conn.commit()
c.execute('''SELECT id, text, created_at, likes FROM comments
WHERE video_id = ? ORDER BY created_at DESC LIMIT 1''', (video_id,))
row = c.fetchone()
conn.close()
return jsonify({
'id': row[0], 'text': row[1], 'created_at': row[2], 'likes': row[3]
})
@app.route('/api/comments/<video_id>', methods=['GET'])
def get_comments(video_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''SELECT id, text, created_at, likes FROM comments
WHERE video_id = ? ORDER BY created_at DESC LIMIT 100''', (video_id,))
comments = [{'id': row[0], 'text': row[1], 'created_at': row[2], 'likes': row[3]} for row in c.fetchall()]
conn.close()
return jsonify({'comments': comments})
@app.route('/api/stats')
def get_stats():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT COUNT(*) FROM videos')
total_videos = c.fetchone()[0]
c.execute('SELECT SUM(size) FROM videos')
total_size = c.fetchone()[0] or 0
c.execute('SELECT SUM(views) FROM videos')
total_views = c.fetchone()[0] or 0
conn.close()
return jsonify({
'total_videos': total_videos,
'total_size': total_size,
'total_views': total_views,
'video_dir_size': get_dir_size(VIDEO_DIR),
'thumb_dir_size': get_dir_size(THUMBNAIL_DIR),
'cache_dir_size': get_dir_size(CACHE_DIR),
'disk_free': shutil.disk_usage(BASE_DIR).free,
'disk_total': shutil.disk_usage(BASE_DIR).total,
'version': VERSION
})
@app.route('/api/delete/<video_id>', methods=['DELETE'])
def delete_video(video_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT filename FROM videos WHERE id = ?', (video_id,))
row = c.fetchone()
if not row:
conn.close()
return jsonify({'error': 'Video not found'}), 404
video_path = os.path.join(VIDEO_DIR, row[0])
if os.path.exists(video_path):
os.remove(video_path)
thumb_path = os.path.join(THUMBNAIL_DIR, f"{video_id}.jpg")
if os.path.exists(thumb_path):
os.remove(thumb_path)
hls_dir = os.path.join(CACHE_DIR, video_id)
if os.path.exists(hls_dir):
shutil.rmtree(hls_dir)
c.execute('DELETE FROM videos WHERE id = ?', (video_id,))
c.execute('DELETE FROM comments WHERE video_id = ?', (video_id,))
conn.commit()
conn.close()
return jsonify({'message': 'Video deleted'})
@app.route('/api/categories')
def get_categories():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT DISTINCT category, COUNT(*) FROM videos WHERE status = "ready" GROUP BY category')
categories = [{'name': row[0] or 'other', 'count': row[1]} for row in c.fetchall()]
conn.close()
return jsonify({'categories': categories})
HTML_TEMPLATE = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🎬 HuggingVideo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #0a0a0a; color: #fff; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.3); }
.header h1 { font-size: 28px; display: flex; align-items: center; gap: 10px; }
.header h1 small { font-size: 14px; opacity: 0.7; font-weight: normal; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.upload-area { background: #1a1a1a; border: 2px dashed #444; border-radius: 15px; padding: 40px; text-align: center; margin-bottom: 30px; transition: all 0.3s; cursor: pointer; }
.upload-area:hover { border-color: #667eea; background: #222; }
.upload-area.dragover { border-color: #667eea; background: #2a2a3a; }
.upload-btn { background: #667eea; color: white; border: none; padding: 12px 30px; border-radius: 25px; font-size: 16px; cursor: pointer; transition: all 0.3s; }
.upload-btn:hover { background: #764ba2; transform: scale(1.05); }
.search-bar { width: 100%; max-width: 500px; padding: 12px 20px; border-radius: 25px; border: none; background: #1a1a1a; color: #fff; font-size: 16px; margin: 20px 0; }
.search-bar:focus { outline: 2px solid #667eea; }
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
.video-card { background: #1a1a1a; border-radius: 12px; overflow: hidden; transition: all 0.3s; cursor: pointer; }
.video-card:hover { transform: translateY(-5px); box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3); }
.video-thumb { width: 100%; height: 180px; object-fit: cover; background: #111; }
.video-info { padding: 15px; }
.video-title { font-size: 16px; font-weight: bold; margin-bottom: 5px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.video-meta { color: #888; font-size: 12px; display: flex; gap: 15px; flex-wrap: wrap; }
.video-meta span { display: flex; align-items: center; gap: 5px; }
.video-category { display: inline-block; background: #667eea33; color: #667eea; padding: 2px 12px; border-radius: 12px; font-size: 11px; margin-top: 5px; }
.player-container { background: #000; border-radius: 12px; overflow: hidden; margin: 20px 0; }
.player-container video { width: 100%; max-height: 600px; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.92); z-index: 999; overflow-y: auto; }
.modal-content { max-width: 900px; margin: 40px auto; padding: 20px; }
.close-modal { position: fixed; top: 20px; right: 40px; color: #fff; font-size: 40px; cursor: pointer; z-index: 1000; }
.close-modal:hover { color: #667eea; }
.spinner { border: 4px solid #f3f3f3; border-top: 4px solid #667eea; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 15px; margin: 20px 0; }
.stat-card { background: #1a1a1a; padding: 15px; border-radius: 10px; text-align: center; }
.stat-value { font-size: 22px; font-weight: bold; color: #667eea; }
.stat-label { color: #888; font-size: 11px; margin-top: 5px; }
.comment-section { margin-top: 20px; }
.comment-input { width: 100%; padding: 10px; border-radius: 8px; border: 1px solid #333; background: #111; color: #fff; margin: 10px 0; }
.comment { background: #1a1a1a; padding: 10px; border-radius: 8px; margin: 5px 0; }
.comment-time { color: #666; font-size: 11px; }
.category-filter { display: flex; gap: 10px; flex-wrap: wrap; margin: 15px 0; }
.category-btn { background: #1a1a1a; border: 1px solid #333; color: #888; padding: 6px 16px; border-radius: 20px; cursor: pointer; transition: all 0.3s; }
.category-btn:hover, .category-btn.active { background: #667eea; border-color: #667eea; color: #fff; }
.video-actions { display: flex; gap: 15px; margin: 10px 0; flex-wrap: wrap; }
.action-btn { background: #1a1a1a; border: none; color: #888; padding: 8px 20px; border-radius: 20px; cursor: pointer; transition: all 0.3s; display: flex; align-items: center; gap: 5px; }
.action-btn:hover { background: #333; color: #fff; }
.action-btn.liked { color: #667eea; }
.action-btn.disliked { color: #ff6b6b; }
@media (max-width: 600px) {
.video-grid { grid-template-columns: 1fr; }
.modal-content { margin: 10px; padding: 10px; }
.close-modal { right: 20px; font-size: 30px; }
.stats { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<div class="header">
<div class="container">
<h1>🎬 HuggingVideo <small>v1.0</small></h1>
</div>
</div>
<div class="container">
<div class="upload-area" id="uploadArea">
<h2>📤 Загрузить видео</h2>
<p style="color: #888; margin: 10px 0;">Перетащите файл или нажмите для выбора (до 500MB)</p>
<input type="file" id="fileInput" accept="video/*" style="display: none;">
<button class="upload-btn" onclick="document.getElementById('fileInput').click()">Выбрать видео</button>
<div id="uploadProgress" style="display: none; margin-top: 15px;">
<div style="background: #333; border-radius: 10px; height: 10px; overflow: hidden;">
<div id="progressBar" style="background: #667eea; height: 100%; width: 0%; transition: width 0.3s;"></div>
</div>
<p id="progressText" style="color: #888; margin-top: 5px;">Загрузка...</p>
</div>
</div>
<div class="stats" id="stats"></div>
<div class="category-filter" id="categoryFilter">
<button class="category-btn active" data-category="" onclick="filterCategory('')">Все</button>
</div>
<input type="text" class="search-bar" id="searchInput" placeholder="🔍 Поиск видео..." oninput="searchVideos(this.value)">
<div id="videoList" class="video-grid"></div>
<div id="loadingSpinner" class="spinner"></div>
</div>
<div class="modal" id="playerModal">
<span class="close-modal" onclick="closePlayer()">&times;</span>
<div class="modal-content">
<div class="player-container">
<video id="player" controls autoplay></video>
</div>
<div id="videoDetails"></div>
<div class="video-actions" id="videoActions"></div>
<div class="comment-section">
<h4>💬 Комментарии</h4>
<input type="text" class="comment-input" id="commentInput" placeholder="Написать комментарий..." onkeypress="if(event.key==='Enter')addComment()">
<div id="comments"></div>
</div>
</div>
</div>
<script>
let currentVideoId = null;
let allVideos = [];
let currentCategory = '';
document.getElementById('fileInput').addEventListener('change', async function(e) {
const file = this.files[0];
if (!file) return;
const formData = new FormData();
formData.append('video', file);
formData.append('title', file.name.replace(/\\.[^/.]+$/, ''));
formData.append('description', '');
formData.append('category', 'other');
document.getElementById('uploadProgress').style.display = 'block';
document.getElementById('progressBar').style.width = '0%';
try {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
document.getElementById('progressBar').style.width = percent + '%';
document.getElementById('progressText').textContent = `Загрузка: ${Math.round(percent)}%`;
}
};
xhr.onload = function() {
document.getElementById('uploadProgress').style.display = 'none';
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
alert('✅ Видео загружено! Обработка началась.');
loadVideos();
} else {
alert('❌ Ошибка: ' + xhr.responseText);
}
};
xhr.send(formData);
} catch(e) {
alert('Ошибка загрузки: ' + e.message);
}
});
const uploadArea = document.getElementById('uploadArea');
uploadArea.addEventListener('dragover', function(e) {
e.preventDefault();
this.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', function(e) {
this.classList.remove('dragover');
});
uploadArea.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
document.getElementById('fileInput').files = files;
document.getElementById('fileInput').dispatchEvent(new Event('change'));
}
});
async function loadVideos() {
document.getElementById('loadingSpinner').style.display = 'block';
try {
const url = currentCategory ? `/api/videos?category=${currentCategory}` : '/api/videos';
const response = await fetch(url);
const data = await response.json();
allVideos = data.videos;
renderVideos(allVideos);
loadStats();
loadCategories();
} catch(e) {
console.error(e);
} finally {
document.getElementById('loadingSpinner').style.display = 'none';
}
}
function renderVideos(videos) {
const grid = document.getElementById('videoList');
if (videos.length === 0) {
grid.innerHTML = '<p style="text-align:center;color:#888;grid-column:1/-1;">😕 Видео не найдены</p>';
return;
}
grid.innerHTML = videos.map(v => `
<div class="video-card" onclick="openPlayer('${v.id}')">
<div style="position:relative;">
<img class="video-thumb" src="${v.thumbnail_url}" alt="${v.title}" onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22320%22 height=%22180%22%3E%3Crect fill=%22%231a1a1a%22 width=%22320%22 height=%22180%22/%3E%3Ctext x=%22160%22 y=%2290%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2230%22%3E🎬%3C/text%3E%3C/svg%3E'">
${v.duration ? `<div class="video-duration">${Math.floor(v.duration/60)}:${String(v.duration%60).padStart(2,'0')}</div>` : ''}
</div>
<div class="video-info">
<div class="video-title">${v.title}</div>
<div class="video-meta">
<span>👁 ${v.views}</span>
<span>👍 ${v.likes}</span>
<span>📅 ${new Date(v.upload_date).toLocaleDateString()}</span>
</div>
${v.category && v.category !== 'other' ? `<div class="video-category">${v.category}</div>` : ''}
</div>
</div>
`).join('');
}
async function loadStats() {
try {
const response = await fetch('/api/stats');
const stats = await response.json();
document.getElementById('stats').innerHTML = `
<div class="stat-card"><div class="stat-value">${stats.total_videos}</div><div class="stat-label">Видео</div></div>
<div class="stat-card"><div class="stat-value">${(stats.total_size / 1024 / 1024).toFixed(1)} MB</div><div class="stat-label">Всего</div></div>
<div class="stat-card"><div class="stat-value">${stats.total_views}</div><div class="stat-label">Просмотров</div></div>
<div class="stat-card"><div class="stat-value">${(stats.disk_free / 1024 / 1024 / 1024).toFixed(1)} GB</div><div class="stat-label">Свободно</div></div>
`;
} catch(e) {}
}
async function loadCategories() {
try {
const response = await fetch('/api/categories');
const data = await response.json();
const filter = document.getElementById('categoryFilter');
filter.innerHTML = '<button class="category-btn active" data-category="" onclick="filterCategory(\'\')">Все</button>';
data.categories.forEach(c => {
if (c.name && c.name !== 'other') {
filter.innerHTML += `<button class="category-btn" data-category="${c.name}" onclick="filterCategory('${c.name}')">${c.name} (${c.count})</button>`;
}
});
} catch(e) {}
}
function filterCategory(category) {
currentCategory = category;
document.querySelectorAll('.category-btn').forEach(b => b.classList.remove('active'));
document.querySelector(`.category-btn[data-category="${category}"]`)?.classList.add('active');
loadVideos();
}
async function searchVideos(query) {
if (!query) { loadVideos(); return; }
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
renderVideos(data.videos);
} catch(e) {}
}
async function openPlayer(videoId) {
currentVideoId = videoId;
document.getElementById('playerModal').style.display = 'block';
document.getElementById('player').pause();
try {
const response = await fetch(`/api/video/${videoId}`);
const data = await response.json();
document.getElementById('videoDetails').innerHTML = `
<h2>${data.title}</h2>
<p style="color:#888;">${data.description || ''}</p>
<div style="display:flex;gap:20px;color:#888;font-size:14px;">
<span>👁 ${data.views}</span>
<span>👍 ${data.likes}</span>
<span>👎 ${data.dislikes}</span>
<span>⏱ ${Math.floor(data.duration/60)}:${String(data.duration%60).padStart(2,'0')}</span>
${data.category && data.category !== 'other' ? `<span>📁 ${data.category}</span>` : ''}
</div>
`;
document.getElementById('videoActions').innerHTML = `
<button class="action-btn" onclick="likeVideo()">👍 ${data.likes}</button>
<button class="action-btn" onclick="dislikeVideo()">👎 ${data.dislikes}</button>
<button class="action-btn" onclick="shareVideo()">🔗 Поделиться</button>
<button class="action-btn" onclick="deleteVideo()" style="color:#ff6b6b;">🗑 Удалить</button>
`;
const videoEl = document.getElementById('player');
videoEl.src = data.video_url || `/api/video/${videoId}/stream`;
videoEl.load();
videoEl.play();
loadComments(videoId);
} catch(e) {
console.error(e);
}
}
function closePlayer() {
document.getElementById('playerModal').style.display = 'none';
document.getElementById('player').pause();
document.getElementById('player').src = '';
}
async function likeVideo() {
if (!currentVideoId) return;
const response = await fetch(`/api/like/${currentVideoId}`, { method: 'POST' });
const data = await response.json();
openPlayer(currentVideoId);
}
async function dislikeVideo() {
if (!currentVideoId) return;
const response = await fetch(`/api/dislike/${currentVideoId}`, { method: 'POST' });
const data = await response.json();
openPlayer(currentVideoId);
}
function shareVideo() {
if (!currentVideoId) return;
const url = window.location.href + `?v=${currentVideoId}`;
navigator.clipboard.writeText(url).then(() => alert('🔗 Ссылка скопирована!'));
}
async function deleteVideo() {
if (!currentVideoId) return;
if (!confirm('Удалить видео?')) return;
const response = await fetch(`/api/delete/${currentVideoId}`, { method: 'DELETE' });
if (response.ok) {
alert('🗑 Видео удалено');
closePlayer();
loadVideos();
}
}
async function addComment() {
const input = document.getElementById('commentInput');
const text = input.value.trim();
if (!text || !currentVideoId) return;
const response = await fetch(`/api/comment/${currentVideoId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
if (response.ok) {
input.value = '';
loadComments(currentVideoId);
}
}
async function loadComments(videoId) {
try {
const response = await fetch(`/api/comments/${videoId}`);
const data = await response.json();
document.getElementById('comments').innerHTML = data.comments.map(c => `
<div class="comment">
<div>${c.text}</div>
<div class="comment-time">${new Date(c.created_at).toLocaleString()} 👍 ${c.likes}</div>
</div>
`).join('') || '<p style="color:#666;">Пока нет комментариев</p>';
} catch(e) {}
}
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('v')) {
openPlayer(urlParams.get('v'));
}
loadVideos();
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closePlayer();
});
</script>
</body>
</html>
'''
if __name__ == '__main__':
app.run(host='0.0.0.0', port=7860, debug=True)