Spaces:
Sleeping
Sleeping
| # 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 | |
| def index(): | |
| return render_template_string(HTML_TEMPLATE) | |
| 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}) | |
| 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 '' | |
| }) | |
| 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 | |
| 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() | |
| 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 | |
| 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 | |
| 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}) | |
| 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}) | |
| 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}) | |
| 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] | |
| }) | |
| 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}) | |
| 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 | |
| }) | |
| 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'}) | |
| 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()">×</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) |