diff --git a/.gitignore b/.gitignore index e98735a..8aff3f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ cache/ __pycache__/ node_modules +.tmp +CLAUDE.md +styleguide.md +medialib.db diff --git a/app.py b/app.py index 096395c..1cc2f5e 100644 --- a/app.py +++ b/app.py @@ -1,35 +1,171 @@ -from flask import Flask, render_template, request, jsonify +from flask import Flask, render_template, request, jsonify, send_from_directory, session, redirect, url_for +from functools import wraps import os import json -from mediascan import scan_medialib, load_config, save_config, list_media_files, filter_media_files, human_readable_size, get_media_info_with_ffprobe, media_get_from_cache, media_create_cache, media_remove_cache, get_single_media_by_path +from mediascan import scan_medialib, load_config, save_config, get_single_media_by_path, get_media_from_db, get_media_info_with_ffprobe +from thumbnails import get_or_generate_thumbs, invalidate_thumbs, get_thumbs_dir +from audio import extract_audio_track, remove_audio_track, add_audio_track +from db import init_db, get_file_by_path, create_operation, get_file_operations, get_operation_by_id, get_file_path_by_id, update_file_media_info, mark_backup_deleted, get_all_audio_tracks, get_audio_track_by_id, delete_audio_track, get_file_backup_paths, delete_file_record, get_latest_operation_by_backup_path, get_app_stats, get_users_count, create_user, get_user_by_username, get_user_by_id, update_user_password, get_notifications, get_unread_count, mark_notification_read, mark_all_notifications_read, delete_notification, delete_all_notifications +from werkzeug.security import generate_password_hash, check_password_hash from urllib.parse import unquote from flask_socketio import SocketIO -import shutil -from transcodate import transcode_file +from transcodate import transcode_file, detect_available_accelerators + app = Flask(__name__) -socketio = SocketIO(app) # Load configuration CONFIG_FILE = 'config.json' config = load_config(CONFIG_FILE) +# Secret key: generate once and persist in config +if not config.get('secret_key'): + config['secret_key'] = os.urandom(32).hex() + save_config(CONFIG_FILE, config) +app.secret_key = config['secret_key'] + +socketio = SocketIO(app) + GStorage = { - "scaning_state": "inaction", # values: inaction, inprogress - "outdated_cache": [] + "scaning_state": "inaction" # values: inaction, inprogress } transcoding_tasks = {} +available_accelerators = detect_available_accelerators() +init_db() + + +# AUTH + +def is_file_transcoding(file_path): + return any(t.get("file") == file_path for t in transcoding_tasks.values()) + + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if 'user_id' not in session: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'error': 'Unauthorized'}), 401 + return redirect(url_for('login')) + return f(*args, **kwargs) + return decorated + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if get_users_count() == 0: + return redirect(url_for('register')) + if 'user_id' in session: + return redirect(url_for('index')) + if request.method == 'POST': + data = request.json + user = get_user_by_username(data.get('username', '')) + if user and check_password_hash(user['password_hash'], data.get('password', '')): + session['user_id'] = user['id'] + session['username'] = user['username'] + return jsonify({'status': 'ok'}) + return jsonify({'error': 'Invalid username or password'}), 401 + return render_template('login.html') + + +@app.route('/logout') +def logout(): + session.clear() + return redirect(url_for('login')) + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if get_users_count() > 0: + return redirect(url_for('login')) + if request.method == 'POST': + data = request.json + username = data.get('username', '').strip() + password = data.get('password', '') + if not username or len(password) < 6: + return jsonify({'error': 'Username required and password must be at least 6 characters'}), 400 + if get_user_by_username(username): + return jsonify({'error': 'Username already taken'}), 400 + create_user(username, generate_password_hash(password), is_superadmin=True) + user = get_user_by_username(username) + session['user_id'] = user['id'] + session['username'] = user['username'] + return jsonify({'status': 'ok'}) + return render_template('register.html') + + +@app.route('/reset-password', methods=['GET', 'POST']) +def reset_password(): + if not config.get('reset_admin_password'): + return redirect(url_for('login')) + if request.method == 'POST': + data = request.json + password = data.get('password', '') + if len(password) < 6: + return jsonify({'error': 'Password must be at least 6 characters'}), 400 + # Reset password for the superadmin (first user) + from db import get_connection + with get_connection() as conn: + row = conn.execute("SELECT id FROM users WHERE is_superadmin = 1 ORDER BY id LIMIT 1").fetchone() + if not row: + return jsonify({'error': 'No superadmin found'}), 404 + update_user_password(row['id'], generate_password_hash(password)) + config.pop('reset_admin_password', None) + save_config(CONFIG_FILE, config) + return jsonify({'status': 'ok'}) + return render_template('reset-password.html') # ROUTING @app.route('/') +@login_required def index(): return render_template('index.html') +@app.route("/audio-tracks") +@login_required +def audio_tracks_page(): + return render_template('audio-tracks.html') + + +@app.route("/originals") +@login_required +def originals_page(): + return render_template('originals.html') + + +@app.route('/originals/list', methods=['GET']) +@login_required +def originals_list(): + backup_dir = config.get('transcoded_directory', 'transcoded_files') + if not os.path.isdir(backup_dir): + return jsonify([]) + + result = [] + for fname in sorted(os.listdir(backup_dir)): + fpath = os.path.join(backup_dir, fname) + if not os.path.isfile(fpath): + continue + rec = get_latest_operation_by_backup_path(fpath) or {} + result.append({ + 'operation_id': rec.get('operation_id'), + 'backup_path': fpath, + 'backup_name': fname, + 'size_bytes': os.path.getsize(fpath), + 'started_at': rec.get('started_at'), + 'source_name': rec.get('source_name'), + 'source_path': rec.get('source_path'), + }) + + result.sort(key=lambda x: x.get('started_at') or '', reverse=True) + return jsonify(result) + + @app.route("/single") +@login_required def single_media(): path = request.args.get("path", "") if not path: @@ -38,10 +174,11 @@ path_to_mediafile = unquote(path) media_file = get_single_media_by_path(path_to_mediafile) - return render_template('single.html', file=media_file) + return render_template('single.html', file=media_file, accelerators=available_accelerators) @app.route("/single-json") +@login_required def single_media_json(): path = request.args.get("path", "") if not path: @@ -52,30 +189,46 @@ return jsonify(get_single_media_by_path(path_to_mediafile)) +@app.route('/scan-status') +@login_required +def scan_status(): + return jsonify({'scanning': GStorage.get('scaning_state') == 'inprogress'}) + + +@app.route('/file-status') +@login_required +def file_status(): + path = request.args.get('path', '') + return jsonify({'transcoding': is_file_transcoding(path)}) + + @app.route('/media-list/clear-cache') +@login_required def media_list_clear_cache(): if GStorage["scaning_state"] == "inprogress": return jsonify({"status": True}) - media_remove_cache(config["cache_dir"], GStorage) scan_medialib(config, GStorage, socketio) return jsonify({"status": True}) @app.route('/media-list', methods=['GET']) +@login_required def media_list(): - filtered_files = media_get_from_cache(config["cache_dir"]) - if filtered_files == None: - filtered_files = scan_medialib(config, GStorage, socketio) + files = get_media_from_db() + if not files: + scan_medialib(config, GStorage, socketio) + files = [] - if GStorage["scaning_state"] == "inprogress" and filtered_files != None and not len(filtered_files): - response = {"status": "scaning", "data": []}; + if GStorage["scaning_state"] == "inprogress": + response = {"status": "scaning", "data": files} else: - response = {"status": True, "data": filtered_files} + response = {"status": True, "data": files} return jsonify(response) @app.route('/process-media', methods=['POST']) +@login_required def process_media(): data = request.json file_path = data.get('path') @@ -84,41 +237,64 @@ crf = data.get('crf') preset = data.get('preset-libx') cpu_used = data.get('preset-vp9') + acceleration = data.get('acceleration', 'CPU') + delete_original = data.get('delete_original', False) if codec == "libaom-av1": cpu_used = data.get('preset-av1') if not file_path or not os.path.exists(file_path): return jsonify({"error": "File path is invalid or file does not exist"}), 400 - # Step 1: Copy the file to the transcoded directory - dest_dir = config.get('transcoded_directory', 'transcoded_files') - os.makedirs(dest_dir, exist_ok=True) - dest_path = os.path.join(dest_dir, os.path.basename(file_path)) - socketio.emit("progress", { - "task": { - "file": file_path - }, - "message": f"Copying the original file to {dest_path}. Waiting" - }) - shutil.copy(file_path, dest_path) + if is_file_transcoding(file_path): + return jsonify({"error": "File is already being transcoded"}), 409 - # Step 2: Start transcoding + # Step 1: Prepare backup destination (skipped when delete_original=True — no point copying) + if delete_original: + dest_path = None + else: + dest_dir = config.get('transcoded_directory', 'transcoded_files') + os.makedirs(dest_dir, exist_ok=True) + dest_path = os.path.join(dest_dir, os.path.basename(file_path)) + + # Step 2: Log operation to DB + operation_id = None + db_file = get_file_by_path(file_path) + if db_file: + snapshot = json.loads(db_file["media_info"]) if db_file["media_info"] else None + operation_id = create_operation( + file_id=db_file["id"], + op_type="transcoding", + params={"codec": codec, "resolution": resolution, "crf": crf, + "preset": preset, "acceleration": acceleration, + "original_size_bytes": db_file["size_bytes"]}, + snapshot_before=snapshot, + backup_path=dest_path + ) + + # Step 3: Start transcoding (copy happens at the beginning of the task) + user_id = session.get('user_id') socketio.start_background_task( - target=transcode_file, + target=transcode_file, transcoding_tasks=transcoding_tasks, - socketio=socketio, - file_path=file_path, - codec=codec, - resolution=resolution, - crf=crf, + socketio=socketio, + file_path=file_path, + dest_path=dest_path, + acceleration=acceleration, + codec=codec, + resolution=resolution, + crf=crf, preset=preset, - cpu_used=cpu_used + cpu_used=cpu_used, + operation_id=operation_id, + delete_original=delete_original, + user_id=user_id ) return jsonify({"status": "processing started", "file": file_path}), 202 @app.route('/stop-transcoding', methods=['GET']) +@login_required def stop_transcoding(): task_id = request.args.get("task_id") @@ -131,17 +307,384 @@ return jsonify({"status": "error", "message": "Task not found"}), 404 -@app.route('/configure', methods=['GET', 'POST']) +@app.route('/thumbnails/', methods=['GET']) +@login_required +def thumbnails(file_id): + from db import get_connection + with get_connection() as conn: + row = conn.execute("SELECT path, media_info FROM files WHERE id = ?", (file_id,)).fetchone() + if not row: + return jsonify({"error": "File not found"}), 404 + + paths = get_or_generate_thumbs(file_id, row["path"], row["media_info"]) + urls = [f"/thumbs/{file_id}/{os.path.basename(p)}" for p in paths] + return jsonify(urls) + + +@app.route('/thumbs//') +@login_required +def serve_thumb(file_id, filename): + return send_from_directory(os.path.abspath(get_thumbs_dir(file_id)), filename) + + +@app.route('/file-history', methods=['GET']) +@login_required +def file_history(): + path = unquote(request.args.get("path", "")) + if not path: + return jsonify({"error": "Path not provided"}), 400 + + operations = get_file_operations(path) + for op in operations: + if op.get("params"): + op["params"] = json.loads(op["params"]) + if op.get("snapshot_before"): + op["snapshot_before"] = json.loads(op["snapshot_before"]) + + return jsonify(operations) + + +def restore_file_task(socketio, operation_id, backup_path, file_path, user_id=None): + CHUNK_SIZE = 1024 * 1024 # 1 MB + tmp_path = file_path + ".restoring" + try: + total = os.path.getsize(backup_path) + copied = 0 + + with open(backup_path, 'rb') as src, open(tmp_path, 'wb') as dst: + while True: + buf = src.read(CHUNK_SIZE) + if not buf: + break + dst.write(buf) + copied += len(buf) + percent = int(copied / total * 100) + socketio.emit('restore-progress', { + 'operation_id': operation_id, + 'percent': percent + }) + + os.replace(tmp_path, file_path) + + if os.path.exists(backup_path): + os.remove(backup_path) + mark_backup_deleted(operation_id) + + fresh_info = get_media_info_with_ffprobe(file_path) + if fresh_info: + update_file_media_info(file_path, os.path.getsize(file_path), fresh_info) + + from notifications import notify + notify(socketio, user_id, "success", f"Original restored: {os.path.basename(file_path)}") + socketio.emit('restore-completed', { + 'operation_id': operation_id, + 'file': file_path + }) + + except Exception as e: + if os.path.exists(tmp_path): + os.remove(tmp_path) + from notifications import notify + notify(socketio, user_id, "error", f"Restore failed: {os.path.basename(file_path)}", str(e)) + socketio.emit('restore-error', { + 'operation_id': operation_id, + 'message': str(e) + }) + + +@app.route('/restore-original', methods=['POST']) +@login_required +def restore_original(): + operation_id = request.json.get('operation_id') + if not operation_id: + return jsonify({"error": "operation_id is required"}), 400 + + op = get_operation_by_id(operation_id) + if not op: + return jsonify({"error": "Operation not found"}), 404 + + backup_path = op.get('backup_path') + if not backup_path or not os.path.exists(backup_path): + return jsonify({"error": "Backup file not found"}), 404 + + file_path = get_file_path_by_id(op['file_id']) + if not file_path: + return jsonify({"error": "Original file record not found"}), 404 + + socketio.start_background_task( + target=restore_file_task, + socketio=socketio, + operation_id=operation_id, + backup_path=backup_path, + file_path=file_path, + user_id=session.get('user_id') + ) + + return jsonify({"status": "restore started"}), 202 + + +@app.route('/delete-file', methods=['POST']) +@login_required +def delete_file(): + file_path = request.json.get('path') + if not file_path: + return jsonify({"error": "path is required"}), 400 + + if is_file_transcoding(file_path): + return jsonify({"error": "Cannot delete file while transcoding is in progress"}), 409 + + db_file = get_file_by_path(file_path) + if not db_file: + return jsonify({"error": "File not found in database"}), 404 + + file_id = db_file["id"] + + # Delete all backup copies + for backup_path in get_file_backup_paths(file_id): + if os.path.exists(backup_path): + os.remove(backup_path) + + # Delete thumbnail cache + from thumbnails import invalidate_thumbs + invalidate_thumbs(file_id) + + # Delete the video file itself + if os.path.exists(file_path): + os.remove(file_path) + + # Remove DB records (operations + file row), audio_tracks are kept + delete_file_record(file_id) + + return jsonify({"status": "deleted"}) + + +@app.route('/delete-backup', methods=['POST']) +@login_required +def delete_backup(): + data = request.json + operation_id = data.get('operation_id') + direct_path = data.get('backup_path') + + if operation_id: + op = get_operation_by_id(operation_id) + if not op: + return jsonify({"error": "Operation not found"}), 404 + backup_path = op.get('backup_path') + if not backup_path or not os.path.exists(backup_path): + return jsonify({"error": "Backup file not found"}), 404 + os.remove(backup_path) + mark_backup_deleted(operation_id) + elif direct_path: + if not os.path.exists(direct_path): + return jsonify({"error": "Backup file not found"}), 404 + os.remove(direct_path) + else: + return jsonify({"error": "operation_id or backup_path is required"}), 400 + + return jsonify({"status": "deleted"}) + + +@app.route('/audio/extract', methods=['POST']) +@login_required +def audio_extract(): + data = request.json + file_path = data.get('path') + track_index = data.get('track_index') + track_meta = data.get('track_meta', {}) + + if not file_path or not os.path.exists(file_path): + return jsonify({"error": "File not found"}), 400 + if track_index is None: + return jsonify({"error": "track_index is required"}), 400 + if is_file_transcoding(file_path): + return jsonify({"error": "Audio operations are unavailable while transcoding is in progress"}), 409 + + output_dir = config.get('audio_tracks_directory', 'audio-tracks') + socketio.start_background_task( + target=extract_audio_track, + socketio=socketio, + file_path=file_path, + track_index=int(track_index), + track_meta=track_meta, + output_dir=output_dir, + user_id=session.get('user_id') + ) + return jsonify({"status": "started"}), 202 + + +@app.route('/audio/remove', methods=['POST']) +@login_required +def audio_remove(): + data = request.json + file_path = data.get('path') + track_index = data.get('track_index') + + if not file_path or not os.path.exists(file_path): + return jsonify({"error": "File not found"}), 400 + if track_index is None: + return jsonify({"error": "track_index is required"}), 400 + if is_file_transcoding(file_path): + return jsonify({"error": "Audio operations are unavailable while transcoding is in progress"}), 409 + + socketio.start_background_task( + target=remove_audio_track, + socketio=socketio, + file_path=file_path, + track_index=int(track_index), + user_id=session.get('user_id') + ) + return jsonify({"status": "started"}), 202 + + +@app.route('/audio/add', methods=['POST']) +@login_required +def audio_add(): + data = request.json + file_path = data.get('path') + audio_track_id = data.get('audio_track_id') + + if not file_path or not os.path.exists(file_path): + return jsonify({"error": "File not found"}), 400 + if not audio_track_id: + return jsonify({"error": "audio_track_id is required"}), 400 + if is_file_transcoding(file_path): + return jsonify({"error": "Audio operations are unavailable while transcoding is in progress"}), 409 + + track = get_audio_track_by_id(audio_track_id) + if not track or not os.path.exists(track['path']): + return jsonify({"error": "Audio track not found"}), 404 + + db_file = get_file_by_path(file_path) + if not db_file: + return jsonify({"error": "File not in database"}), 400 + + media_info = json.loads(db_file['media_info']) if db_file['media_info'] else {} + duration_str = media_info.get('container', {}).get('duration', '') + import re + m = re.match(r'([\d.]+)', duration_str) + video_duration = float(m.group(1)) if m else None + + if not video_duration: + return jsonify({"error": "Could not determine video duration"}), 400 + + socketio.start_background_task( + target=add_audio_track, + socketio=socketio, + file_path=file_path, + audio_track_path=track['path'], + video_duration=video_duration, + track_title=track.get('title') or '', + user_id=session.get('user_id') + ) + return jsonify({"status": "started"}), 202 + + +@app.route('/audio/tracks', methods=['GET']) +@login_required +def audio_tracks_list(): + return jsonify(get_all_audio_tracks()) + + +@app.route('/audio/tracks/', methods=['DELETE']) +@login_required +def audio_track_delete(track_id): + track = get_audio_track_by_id(track_id) + if not track: + return jsonify({"error": "Track not found"}), 404 + + if os.path.exists(track['path']): + os.remove(track['path']) + delete_audio_track(track_id) + return jsonify({"status": "deleted"}) + + +@app.route('/notifications', methods=['GET']) +@login_required +def notifications_list(): + user_id = session.get('user_id') + items = get_notifications(user_id=user_id) + count = get_unread_count(user_id=user_id) + return jsonify({'items': items, 'unread_count': count}) + + +@app.route('/notifications/unread-count', methods=['GET']) +@login_required +def notifications_unread_count(): + user_id = session.get('user_id') + return jsonify({'count': get_unread_count(user_id=user_id)}) + + +@app.route('/notifications//read', methods=['POST']) +@login_required +def notification_mark_read(notif_id): + mark_notification_read(notif_id) + return jsonify({'status': 'ok'}) + + +@app.route('/notifications/read-all', methods=['POST']) +@login_required +def notifications_read_all(): + mark_all_notifications_read(user_id=session.get('user_id')) + return jsonify({'status': 'ok'}) + + +@app.route('/notifications/', methods=['DELETE']) +@login_required +def notification_delete(notif_id): + delete_notification(notif_id) + return jsonify({'status': 'ok'}) + + +@app.route('/notifications', methods=['DELETE']) +@login_required +def notifications_delete_all(): + delete_all_notifications(user_id=session.get('user_id')) + return jsonify({'status': 'ok'}) + + +@app.route('/stats', methods=['GET']) +@login_required +def stats(): + return jsonify(get_app_stats() or {}) + + +@app.route('/configure', methods=['GET']) +@login_required def configure(): - if request.method == 'POST': - directories = request.json.get('directories', []) - allowed_formats = request.json.get('allowed_formats', []) - config['directories'] = directories - config['allowed_formats'] = allowed_formats - save_config(CONFIG_FILE, config) - return jsonify({"status": "success"}) + return render_template('configure.html') + + +@app.route('/configure/data', methods=['GET']) +@login_required +def configure_data(): return jsonify(config) +@app.route('/configure/save', methods=['POST']) +@login_required +def configure_save(): + data = request.json + directories = [d for d in data.get('directories', []) if d.strip()] + allowed_formats = [f.strip() for f in data.get('allowed_formats', '').split(',') if f.strip()] + audio_tracks_directory = data.get('audio_tracks_directory', '').strip() + transcoded_directory = data.get('transcoded_directory', '').strip() + + config['directories'] = directories + config['allowed_formats'] = allowed_formats + if audio_tracks_directory: + config['audio_tracks_directory'] = audio_tracks_directory + if transcoded_directory: + config['transcoded_directory'] = transcoded_directory + save_config(CONFIG_FILE, config) + return jsonify({"status": "success"}) + + +@socketio.on('connect') +def handle_connect(): + if 'user_id' not in session: + return False # reject unauthorized connections + + if __name__ == '__main__': + # socketio.run(app, host="0.0.0.0", port=5000, debug=True) app.run(debug=True) diff --git a/audio.py b/audio.py new file mode 100644 index 0000000..d290fd6 --- /dev/null +++ b/audio.py @@ -0,0 +1,211 @@ +import os +import subprocess +from db import AUDIO_CODEC_EXT, create_audio_track, create_operation, update_operation, get_file_by_path +from notifications import notify + + +def _get_output_ext(codec): + return AUDIO_CODEC_EXT.get(codec.lower(), ".aac") if codec else ".aac" + + +def extract_audio_track(socketio, file_path, track_index, track_meta, output_dir, user_id=None): + """ + Extract a single audio track from video file. + track_meta: dict with title, language, codec, bitrate, channels + """ + db_file = get_file_by_path(file_path) + + ext = _get_output_ext(track_meta.get("codec")) + lang = track_meta.get("language", "und") + title = track_meta.get("title") or f"track_{track_index}" + base = os.path.splitext(os.path.basename(file_path))[0] + out_filename = f"{base}.{lang}.{track_index}{ext}" + out_path = os.path.join(output_dir, out_filename) + + os.makedirs(output_dir, exist_ok=True) + + operation_id = None + if db_file: + operation_id = create_operation( + file_id=db_file["id"], + op_type="extract_audio", + params={"track_index": track_index, **track_meta}, + snapshot_before=None, + backup_path=None + ) + + socketio.emit("audio-extract-progress", { + "file": file_path, + "track_index": track_index, + "message": "Extracting..." + }) + + cmd = [ + "ffmpeg", "-y", + "-i", file_path, + "-map", f"0:a:{track_index}", + "-c:a", "copy", + out_path + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode == 0: + track_id = None + if db_file: + update_operation(operation_id, "completed") + track_id = create_audio_track( + source_file_id=db_file["id"], + track_index=track_index, + title=title, + language=lang, + codec=track_meta.get("codec"), + bitrate=track_meta.get("bitrate"), + channels=track_meta.get("channels"), + path=out_path + ) + file_name = os.path.basename(file_path) + notify(socketio, user_id, "success", f"Audio track extracted: {file_name}", + f"Track {track_index} → {out_filename}") + socketio.emit("audio-extract-completed", { + "file": file_path, + "track_index": track_index, + "track_id": track_id + }) + else: + if operation_id: + update_operation(operation_id, "failed") + err_msg = result.stderr[-300:] if result.stderr else "Unknown error" + notify(socketio, user_id, "error", f"Audio extract failed: {os.path.basename(file_path)}", err_msg) + socketio.emit("audio-extract-error", { + "file": file_path, + "track_index": track_index, + "message": err_msg + }) + + +def remove_audio_track(socketio, file_path, track_index, user_id=None): + """Remove one audio track from video, remux in place.""" + db_file = get_file_by_path(file_path) + base, ext = os.path.splitext(file_path) + tmp_path = base + ".removing" + ext + + operation_id = None + if db_file: + operation_id = create_operation( + file_id=db_file["id"], + op_type="remove_audio", + params={"track_index": track_index}, + snapshot_before=None, + backup_path=None + ) + + socketio.emit("audio-remove-progress", { + "file": file_path, + "track_index": track_index, + "message": "Removing audio track..." + }) + + cmd = [ + "ffmpeg", "-y", + "-i", file_path, + "-map", "0", + "-map", f"-0:a:{track_index}", + "-c", "copy", + tmp_path + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode == 0: + os.replace(tmp_path, file_path) + if operation_id: + update_operation(operation_id, "completed") + notify(socketio, user_id, "success", f"Audio track removed: {os.path.basename(file_path)}", + f"Track index {track_index} removed") + socketio.emit("audio-remove-completed", { + "file": file_path, + "track_index": track_index + }) + else: + if os.path.exists(tmp_path): + os.remove(tmp_path) + if operation_id: + update_operation(operation_id, "failed") + err_msg = result.stderr[-300:] if result.stderr else "Unknown error" + notify(socketio, user_id, "error", f"Audio remove failed: {os.path.basename(file_path)}", err_msg) + socketio.emit("audio-remove-error", { + "file": file_path, + "track_index": track_index, + "message": err_msg + }) + + +def add_audio_track(socketio, file_path, audio_track_path, video_duration, track_title='', user_id=None): + """ + Add an external audio track to a video file. + Syncs audio to video duration: trims if longer, pads with silence if shorter. + """ + db_file = get_file_by_path(file_path) + base, ext = os.path.splitext(file_path) + tmp_path = base + ".adding_audio" + ext + + operation_id = None + if db_file: + operation_id = create_operation( + file_id=db_file["id"], + op_type="add_audio", + params={"audio_source": audio_track_path}, + snapshot_before=None, + backup_path=None + ) + + socketio.emit("audio-add-progress", { + "file": file_path, + "message": "Adding audio track..." + }) + + # Build audio filter for sync: trim to video duration + pad if shorter + audio_filter = f"atrim=0:{video_duration},apad=whole_dur={video_duration}" + + cmd = [ + "ffmpeg", "-y", + "-i", file_path, + "-i", audio_track_path, + "-map", "0:v", + "-map", "0:a", + "-map", "1:a", + "-filter:a:1", audio_filter, + "-c:v", "copy", + "-c:a", "copy", + "-c:a:1", "aac", + ] + if track_title: + cmd += ["-metadata:s:a:1", f"title={track_title}"] + cmd.append(tmp_path) + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode == 0: + os.replace(tmp_path, file_path) + if operation_id: + update_operation(operation_id, "completed") + from thumbnails import invalidate_thumbs + if db_file: + invalidate_thumbs(db_file["id"]) + notify(socketio, user_id, "success", f"Audio track added: {os.path.basename(file_path)}", + track_title or os.path.basename(audio_track_path)) + socketio.emit("audio-add-completed", { + "file": file_path + }) + else: + if os.path.exists(tmp_path): + os.remove(tmp_path) + if operation_id: + update_operation(operation_id, "failed") + err_msg = result.stderr[-300:] if result.stderr else "Unknown error" + notify(socketio, user_id, "error", f"Audio add failed: {os.path.basename(file_path)}", err_msg) + socketio.emit("audio-add-error", { + "file": file_path, + "message": err_msg + }) diff --git a/config.json b/config.json index 45fe8a5..a2cbfed 100644 --- a/config.json +++ b/config.json @@ -1,8 +1,29 @@ { "directories": [ - "/home/gmikcon/media-storage/NetTera1" + "/home/gmikcon/media-storage/test-data", + "/home/gmikcon/media-storage/NetTera8/films", + "/home/gmikcon/media-storage/NetTera8/serials", + "/home/gmikcon/media-storage/firstraid5/films", + "/home/gmikcon/media-storage/firstraid5/serials", + "/home/gmikcon/media-storage/secraid5_storage/films", + "/home/gmikcon/media-storage/secraid5_storage/serials" ], - "allowed_formats": [".mp4", ".m4p", ".m4v", ".mkv", ".webm", ".flv", ".vob", ".ogv", ".amv", ".mng", ".avi", ".mov", ".viv"], + "allowed_formats": [ + ".mp4", + ".m4p", + ".m4v", + ".mkv", + ".webm", + ".flv", + ".vob", + ".ogv", + ".amv", + ".mng", + ".avi", + ".mov" + ], "cache_dir": "cache", - "transcoded_directory": "/home/gmikcon/media-storage/transcoded" + "transcoded_directory": "/home/gmikcon/media-storage/transcoded", + "audio_tracks_directory": "/home/gmikcon/media-storage/audio-tracks", + "secret_key": "aa50a9f8d26a9c4e0756ea7d595ea947b728a025cfd418dc2c3d0b9e4f56a0e6" } \ No newline at end of file diff --git a/db.py b/db.py new file mode 100644 index 0000000..b80d170 --- /dev/null +++ b/db.py @@ -0,0 +1,444 @@ +import sqlite3 +import json +from datetime import datetime +from contextlib import contextmanager + +DB_PATH = "medialib.db" + + +@contextmanager +def get_connection(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + +AUDIO_CODEC_EXT = { + "aac": ".aac", + "ac3": ".ac3", + "eac3": ".eac3", + "mp3": ".mp3", + "flac": ".flac", + "opus": ".opus", + "vorbis": ".ogg", + "dts": ".dts", +} + + +def init_db(): + with get_connection() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + size_bytes INTEGER, + media_info TEXT, + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id), + type TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + status TEXT NOT NULL DEFAULT 'started', + params TEXT, + snapshot_before TEXT, + backup_path TEXT, + backup_deleted INTEGER NOT NULL DEFAULT 0 + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS audio_tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_file_id INTEGER NOT NULL REFERENCES files(id), + track_index INTEGER NOT NULL, + title TEXT, + language TEXT, + codec TEXT, + bitrate TEXT, + channels INTEGER, + path TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_superadmin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS app_stats ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + type TEXT NOT NULL DEFAULT 'info', + title TEXT NOT NULL, + message TEXT, + is_read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + """) + conn.commit() + + +def upsert_file(path, name, size_bytes, media_info): + now = datetime.utcnow().isoformat() + media_info_json = json.dumps(media_info) if media_info is not None else None + with get_connection() as conn: + conn.execute(""" + INSERT INTO files (path, name, size_bytes, media_info, first_seen, last_seen) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(path) DO UPDATE SET + name = excluded.name, + size_bytes = excluded.size_bytes, + media_info = excluded.media_info, + last_seen = excluded.last_seen + """, (path, name, size_bytes, media_info_json, now, now)) + conn.commit() + row = conn.execute("SELECT id FROM files WHERE path = ?", (path,)).fetchone() + return row["id"] + + +def get_file_path_by_id(file_id): + with get_connection() as conn: + row = conn.execute("SELECT path FROM files WHERE id = ?", (file_id,)).fetchone() + return row["path"] if row else None + + +def get_file_by_path(path): + with get_connection() as conn: + return conn.execute("SELECT * FROM files WHERE path = ?", (path,)).fetchone() + + +def create_operation(file_id, op_type, params, snapshot_before, backup_path): + now = datetime.utcnow().isoformat() + with get_connection() as conn: + cursor = conn.execute(""" + INSERT INTO operations (file_id, type, started_at, status, params, snapshot_before, backup_path) + VALUES (?, ?, ?, 'started', ?, ?, ?) + """, ( + file_id, + op_type, + now, + json.dumps(params) if params is not None else None, + json.dumps(snapshot_before) if snapshot_before is not None else None, + backup_path + )) + conn.commit() + return cursor.lastrowid + + +def update_operation(operation_id, status): + now = datetime.utcnow().isoformat() + with get_connection() as conn: + conn.execute( + "UPDATE operations SET status = ?, finished_at = ? WHERE id = ?", + (status, now, operation_id) + ) + conn.commit() + + +def get_operation_by_id(operation_id): + with get_connection() as conn: + row = conn.execute("SELECT * FROM operations WHERE id = ?", (operation_id,)).fetchone() + return dict(row) if row else None + + +def get_file_operations(path): + with get_connection() as conn: + rows = conn.execute(""" + SELECT o.* + FROM operations o + JOIN files f ON f.id = o.file_id + WHERE f.path = ? + ORDER BY o.started_at DESC + """, (path,)).fetchall() + return [dict(row) for row in rows] + + +def get_all_files(): + with get_connection() as conn: + rows = conn.execute("SELECT * FROM files ORDER BY name").fetchall() + return list(rows) if rows else None + + +def create_audio_track(source_file_id, track_index, title, language, codec, bitrate, channels, path): + now = datetime.utcnow().isoformat() + with get_connection() as conn: + cursor = conn.execute(""" + INSERT INTO audio_tracks (source_file_id, track_index, title, language, codec, bitrate, channels, path, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (source_file_id, track_index, title, language, codec, bitrate, channels, path, now)) + conn.commit() + return cursor.lastrowid + + +def get_all_audio_tracks(): + with get_connection() as conn: + rows = conn.execute(""" + SELECT at.*, f.name AS source_name, f.path AS source_path + FROM audio_tracks at + JOIN files f ON f.id = at.source_file_id + ORDER BY at.created_at DESC + """).fetchall() + return [dict(r) for r in rows] + + +def get_audio_track_by_id(track_id): + with get_connection() as conn: + row = conn.execute("SELECT * FROM audio_tracks WHERE id = ?", (track_id,)).fetchone() + return dict(row) if row else None + + +def delete_audio_track(track_id): + with get_connection() as conn: + conn.execute("DELETE FROM audio_tracks WHERE id = ?", (track_id,)) + conn.commit() + + +def mark_backup_deleted(operation_id): + with get_connection() as conn: + conn.execute( + "UPDATE operations SET backup_deleted = 1, backup_path = NULL WHERE id = ?", + (operation_id,) + ) + conn.commit() + + +def get_latest_operation_by_backup_path(backup_path): + """Return the most recent operation record for a given backup file path.""" + with get_connection() as conn: + row = conn.execute(""" + SELECT o.id AS operation_id, o.started_at, o.backup_path, + f.id AS file_id, f.name AS source_name, f.path AS source_path + FROM operations o + JOIN files f ON f.id = o.file_id + WHERE o.backup_path = ? + ORDER BY o.started_at DESC + LIMIT 1 + """, (backup_path,)).fetchone() + return dict(row) if row else None + + +def get_file_backup_paths(file_id): + """Return all non-null backup_paths for a file's operations.""" + with get_connection() as conn: + rows = conn.execute( + "SELECT backup_path FROM operations WHERE file_id = ? AND backup_path IS NOT NULL AND backup_deleted = 0", + (file_id,) + ).fetchall() + return [row["backup_path"] for row in rows] + + +def delete_file_record(file_id): + """Delete operations history and file record. Audio tracks are kept.""" + with get_connection() as conn: + conn.execute("DELETE FROM operations WHERE file_id = ?", (file_id,)) + conn.execute("DELETE FROM files WHERE id = ?", (file_id,)) + conn.commit() + + +def get_users_count(): + with get_connection() as conn: + return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + + +def create_user(username, password_hash, is_superadmin=False): + now = datetime.utcnow().isoformat() + with get_connection() as conn: + conn.execute( + "INSERT INTO users (username, password_hash, is_superadmin, created_at) VALUES (?, ?, ?, ?)", + (username, password_hash, 1 if is_superadmin else 0, now) + ) + conn.commit() + + +def get_user_by_username(username): + with get_connection() as conn: + row = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone() + return dict(row) if row else None + + +def get_user_by_id(user_id): + with get_connection() as conn: + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return dict(row) if row else None + + +def update_user_password(user_id, password_hash): + with get_connection() as conn: + conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id)) + conn.commit() + + +def get_app_stats(): + with get_connection() as conn: + row = conn.execute("SELECT value FROM app_stats WHERE key = 'transcoding_stats'").fetchone() + if row: + return json.loads(row['value']) + return None + + +def calculate_and_save_stats(): + """Recalculate transcoding savings from DB and cache the result.""" + with get_connection() as conn: + # Total distinct files with at least one completed transcoding op + transcoded_count = conn.execute(""" + SELECT COUNT(DISTINCT file_id) FROM operations + WHERE type = 'transcoding' AND status = 'completed' + """).fetchone()[0] + + # For each file: first completed transcoding op + rows = conn.execute(""" + SELECT f.size_bytes AS current_size, o.params, o.backup_path + FROM files f + JOIN operations o ON o.file_id = f.id + WHERE o.type = 'transcoding' AND o.status = 'completed' + AND o.started_at = ( + SELECT MIN(o2.started_at) FROM operations o2 + WHERE o2.file_id = f.id + AND o2.type = 'transcoding' AND o2.status = 'completed' + ) + """).fetchall() + + total_original = 0 + total_current = 0 + + for row in rows: + current_size = row['current_size'] + if not current_size: + continue + + original_size = None + if row['params']: + try: + original_size = json.loads(row['params']).get('original_size_bytes') + except Exception: + pass + + if not original_size and row['backup_path']: + import os as _os + if _os.path.exists(row['backup_path']): + original_size = _os.path.getsize(row['backup_path']) + + if original_size and original_size > current_size: + total_original += original_size + total_current += current_size + + saved_bytes = total_original - total_current + percent_saved = round(saved_bytes / total_original * 100, 1) if total_original > 0 else 0 + + stats = { + 'transcoded_count': transcoded_count, + 'saved_bytes': saved_bytes, + 'total_original_bytes': total_original, + 'percent_saved': percent_saved, + } + + now = datetime.utcnow().isoformat() + with get_connection() as conn: + conn.execute(""" + INSERT INTO app_stats (key, value, updated_at) VALUES ('transcoding_stats', ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + """, (json.dumps(stats), now)) + conn.commit() + + return stats + + +def update_file_media_info(path, size_bytes, media_info): + now = datetime.utcnow().isoformat() + with get_connection() as conn: + conn.execute(""" + UPDATE files SET size_bytes = ?, media_info = ?, last_seen = ? WHERE path = ? + """, (size_bytes, json.dumps(media_info) if media_info is not None else None, now, path)) + conn.commit() + + +def create_notification(user_id, notif_type, title, message): + now = datetime.utcnow().isoformat() + with get_connection() as conn: + cursor = conn.execute( + "INSERT INTO notifications (user_id, type, title, message, created_at) VALUES (?, ?, ?, ?, ?)", + (user_id, notif_type, title, message, now) + ) + conn.commit() + return cursor.lastrowid + + +def get_notifications(user_id=None, limit=100): + with get_connection() as conn: + if user_id is not None: + rows = conn.execute( + "SELECT * FROM notifications WHERE user_id IS NULL OR user_id = ? ORDER BY created_at DESC LIMIT ?", + (user_id, limit) + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM notifications ORDER BY created_at DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + +def get_unread_count(user_id=None): + with get_connection() as conn: + if user_id is not None: + return conn.execute( + "SELECT COUNT(*) FROM notifications WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)", + (user_id,) + ).fetchone()[0] + return conn.execute("SELECT COUNT(*) FROM notifications WHERE is_read = 0").fetchone()[0] + + +def mark_notification_read(notif_id): + with get_connection() as conn: + conn.execute("UPDATE notifications SET is_read = 1 WHERE id = ?", (notif_id,)) + conn.commit() + + +def mark_all_notifications_read(user_id=None): + with get_connection() as conn: + if user_id is not None: + conn.execute( + "UPDATE notifications SET is_read = 1 WHERE user_id IS NULL OR user_id = ?", + (user_id,) + ) + else: + conn.execute("UPDATE notifications SET is_read = 1") + conn.commit() + + +def delete_notification(notif_id): + with get_connection() as conn: + conn.execute("DELETE FROM notifications WHERE id = ?", (notif_id,)) + conn.commit() + + +def delete_all_notifications(user_id=None): + with get_connection() as conn: + if user_id is not None: + conn.execute( + "DELETE FROM notifications WHERE user_id IS NULL OR user_id = ?", + (user_id,) + ) + else: + conn.execute("DELETE FROM notifications") + conn.commit() diff --git a/mediascan.py b/mediascan.py index af2fe09..eb3f34d 100644 --- a/mediascan.py +++ b/mediascan.py @@ -2,6 +2,8 @@ import json import ffmpeg import subprocess +from db import upsert_file, get_all_files +from notifications import notify def load_config(config_file): @@ -17,33 +19,6 @@ json.dump(config, f, indent=4) -def media_create_cache(cache_dir, data): - filepath = f"{cache_dir}/mediascan.json" - with open(filepath, 'w') as f: - json.dump(data, f, indent=2) - - -def media_remove_cache(cache_dir, GStorage): - GStorage["outdated_cache"] = media_get_from_cache(cache_dir) - if GStorage["outdated_cache"] == None: - GStorage["outdated_cache"] = [] - - filepath = f"{cache_dir}/mediascan.json" - if not os.path.exists(filepath): - return True - - os.remove(filepath) - return True - - -def media_get_from_cache(cache_dir): - filepath = f"{cache_dir}/mediascan.json" - if not os.path.exists(filepath): - return None - - with open(filepath, 'r') as f: - return json.load(f) - def get_media_info_with_ffprobe(file_path): try: @@ -51,36 +26,23 @@ print(f"File not found: {file_path}") return None - # Command to fetch streams - streams_command = [ + command = [ "ffprobe", "-v", "error", "-show_entries", "stream=index,codec_type,codec_name,width,height,bit_rate,channels,channel_layout", "-show_entries", "stream_tags=language,title", - "-of", "json", file_path - ] - - # Command to fetch format information - format_command = [ - "ffprobe", "-v", "error", "-show_entries", "format=duration,bit_rate,size", "-of", "json", file_path ] - streams_result = subprocess.run(streams_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - format_result = subprocess.run(format_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - if streams_result.returncode != 0: - print(f"ffprobe stream error: {streams_result.stderr}") + if result.returncode != 0: + print(f"ffprobe error: {result.stderr}") return None - if format_result.returncode != 0: - print(f"ffprobe format error: {format_result.stderr}") - return None + data = json.loads(result.stdout) - streams_data = json.loads(streams_result.stdout) - format_data = json.loads(format_result.stdout) - - format_info = format_data.get("format", {}) + format_info = data.get("format", {}) container_duration = float(format_info.get("duration", 0)) container_bitrate = int(format_info.get("bit_rate", 0)) // 1000 if format_info.get("bit_rate") else "Unknown" file_size = int(format_info.get("size", 0)) @@ -95,7 +57,7 @@ } } - for stream in streams_data.get("streams", []): + for stream in data.get("streams", []): codec_type = stream.get("codec_type") codec_name = stream.get("codec_name") stream_bitrate = int(stream.get("bit_rate", 0)) // 1000 if stream.get("bit_rate") else None @@ -191,9 +153,12 @@ def get_single_media_by_path(path): + from db import get_file_by_path media_info = get_media_info_with_ffprobe(path) size, size_unit, size_bytes = human_readable_size(os.path.getsize(path)) + db_file = get_file_by_path(path) media_file = { + "id": db_file["id"] if db_file else None, "path": path, "name": os.path.basename(path), "size_bytes": size_bytes, @@ -205,6 +170,26 @@ return media_file +def get_media_from_db(): + rows = get_all_files() + if not rows: + return None + files = [] + for row in rows: + media_info = json.loads(row["media_info"]) if row["media_info"] else {"error": "No info"} + size_bytes = row["size_bytes"] or 0 + size, size_unit, size_bytes_str = human_readable_size(size_bytes) + files.append({ + "path": row["path"], + "name": row["name"], + "size": size, + "size_unit": size_unit, + "size_bytes": size_bytes_str, + "info": media_info + }) + return files + + def bg_scan_medialib(socketio, config, GStorage): GStorage["scaning_state"] = "inprogress"; media_files = list_media_files(socketio, config.get("directories", [])) @@ -231,17 +216,15 @@ else: file["info"] = {"error": "Failed to retrieve media info"} - GStorage["scaning_state"] = "inaction" - - socketio.emit("medialib-scaning-process", { - "message": f"Creating cache..." - }) - media_create_cache(config["cache_dir"], filtered_files) + upsert_file(file["path"], file["name"], int(file["size_bytes"]), file_info) + GStorage["scaning_state"] = "inaction" + + notify(socketio, None, "info", "Media library scan complete", + f"{len(filtered_files)} file(s) indexed") socketio.emit("medialib-scaning-complete", { "data": filtered_files }); - pass def scan_medialib(config, GStorage, socketio): diff --git a/notifications.py b/notifications.py new file mode 100644 index 0000000..dbaeccd --- /dev/null +++ b/notifications.py @@ -0,0 +1,16 @@ +from datetime import datetime +from db import create_notification + + +def notify(socketio, user_id, notif_type, title, message=None): + """Create a persistent notification and push it to connected clients via SocketIO.""" + notif_id = create_notification(user_id, notif_type, title, message) + socketio.emit('notification', { + 'id': notif_id, + 'user_id': user_id, + 'type': notif_type, + 'title': title, + 'message': message, + 'is_read': False, + 'created_at': datetime.utcnow().isoformat() + }) diff --git a/scss/components/file-history.scss b/scss/components/file-history.scss new file mode 100644 index 0000000..f9651fd --- /dev/null +++ b/scss/components/file-history.scss @@ -0,0 +1,46 @@ +#file-history { + .history-item { + padding: 16px 20px; + + .params-table { + margin-top: 10px; + + td { + padding: 4px 8px; + font-size: .9em; + } + + td:first-child { + width: 130px; + color: #666; + } + } + + .snapshot-before { + margin-top: 12px; + + small { + font-size: .85em; + } + + .badge { + font-size: .85em; + padding: 5px 10px; + } + } + + & > .d-flex { + .fw-semibold { + font-size: 1.05em; + } + + small { + font-size: .9em; + } + } + + & > div:last-child small { + font-size: .9em; + } + } +} diff --git a/scss/components/thumbnails.scss b/scss/components/thumbnails.scss new file mode 100644 index 0000000..e32e06a --- /dev/null +++ b/scss/components/thumbnails.scss @@ -0,0 +1,39 @@ +.thumbnails-row { + display: flex; + gap: 8px; + + .thumb-placeholder { + flex: 1; + height: 90px; + background-color: var(--tn-bg-card); + border: 1px solid var(--tn-border); + border-radius: 6px; + animation: thumb-pulse 1.5s ease-in-out infinite; + } + + .thumb-img { + flex: 1; + height: 90px; + object-fit: cover; + border-radius: 6px; + border: 1px solid var(--tn-border); + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s; + + &:hover { + border-color: var(--tn-blue); + box-shadow: 0 0 14px rgba(122, 162, 247, 0.25); + transform: translateY(-2px); + } + } +} + +@keyframes thumb-pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.7; } +} + +#thumb-lightbox .modal-content { + background-color: var(--tn-bg-dark); + border-color: var(--tn-border); +} diff --git a/scss/index.scss b/scss/index.scss index dabfc91..f7c4b9a 100644 --- a/scss/index.scss +++ b/scss/index.scss @@ -1,3 +1,5 @@ @import "main.scss"; @import "components/media-list.scss"; -@import "components/errors-center.scss"; \ No newline at end of file +@import "components/errors-center.scss"; +@import "components/file-history.scss"; +@import "components/thumbnails.scss"; \ No newline at end of file diff --git a/scss/main.scss b/scss/main.scss index f44096a..6b16ecc 100644 --- a/scss/main.scss +++ b/scss/main.scss @@ -1,20 +1,311 @@ +// Tokyo Night palette +:root { + --tn-bg: #1a1b26; + --tn-bg-dark: #16161e; + --tn-bg-card: #1f2335; + --tn-border: #292e42; + --tn-fg: #c0caf5; + --tn-fg-dim: #a9b1d6; + --tn-muted: #565f89; + --tn-blue: #7aa2f7; + --tn-cyan: #7dcfff; + --tn-purple: #bb9af7; + --tn-green: #9ece6a; + --tn-yellow: #e0af68; + --tn-red: #f7768e; +} + +// Bootstrap muted override +.text-muted { color: var(--tn-fg-dim) !important; } + +// Force line breaks for long unbreakable strings (filenames, paths) +h1, h2, h3, h4, h5, h6, +.filename a, +.filepath, +.stream-title, +td, +.file a, +.modal-body strong { + word-break: break-word; + overflow-wrap: anywhere; +} + +// Base +* { box-sizing: border-box; } + +body { + font-family: 'JetBrains Mono', monospace; + background-color: var(--tn-bg); + color: var(--tn-fg); + font-size: 1rem; +} + a { text-decoration: none; + color: var(--tn-blue); + &:hover { color: var(--tn-cyan); } } -/* LOADING SPINNER */ - -.loading-spinner-container { - width: 100%; - margin: 50px auto; - text-align: center; +// Headings +h1, h2, h3, h4, h5, h6 { + color: var(--tn-blue); + letter-spacing: 0.04em; + font-weight: 400; } +// Cards +.card { + background-color: var(--tn-bg-card); + border: 1px solid var(--tn-border); + border-radius: 8px; + transition: border-color 0.2s, box-shadow 0.2s; + + .card-header { + background-color: var(--tn-bg-dark); + border-bottom: 1px solid var(--tn-border); + color: var(--tn-blue); + letter-spacing: 0.04em; + } + + .card-body { color: var(--tn-fg); } +} + +// Tables +.table { + color: var(--tn-fg); + border-color: var(--tn-border); + + thead th { + color: var(--tn-blue); + font-weight: 400; + border-color: var(--tn-border); + letter-spacing: 0.04em; + background-color: var(--tn-bg-dark); + } + + td { + color: var(--tn-fg); + border-color: var(--tn-border); + padding: 6px 10px; + } + + &.table-striped > tbody > tr:nth-of-type(odd) > * { + background-color: rgba(41, 46, 66, 0.4); + color: var(--tn-fg); + } + + &.table-hover > tbody > tr:hover > * { + background-color: rgba(122, 162, 247, 0.08); + color: var(--tn-fg); + } +} + +// DataTables overrides +.dataTables_wrapper { + color: var(--tn-fg); + + .dataTables_filter input, + .dataTables_length select { + background-color: var(--tn-bg-dark); + border: 1px solid var(--tn-border); + color: var(--tn-fg); + border-radius: 4px; + padding: 2px 8px; + font-family: 'JetBrains Mono', monospace; + outline: none; + &:focus { border-color: var(--tn-blue); } + } + + .dataTables_info, + .dataTables_filter label, + .dataTables_length label { color: var(--tn-muted); } + + .dataTables_paginate .paginate_button { + color: var(--tn-muted) !important; + border-radius: 4px !important; + &:hover { background: var(--tn-bg-card) !important; color: var(--tn-blue) !important; border-color: var(--tn-border) !important; } + &.current { background: var(--tn-bg-card) !important; color: var(--tn-blue) !important; border-color: var(--tn-border) !important; } + &.disabled { color: var(--tn-border) !important; } + } +} + +// Buttons +.btn { + font-family: 'JetBrains Mono', monospace; + border-radius: 4px; + letter-spacing: 0.02em; + + &.border-spinner { margin-right: 2px; position: relative; top: 1px; } + &.inprogress { display: inline-flex; gap: 6px; align-items: center; } +} + +.btn-primary { + background-color: var(--tn-blue); + border-color: var(--tn-blue); + color: var(--tn-bg-dark); + &:hover { background-color: var(--tn-cyan); border-color: var(--tn-cyan); color: var(--tn-bg-dark); } +} + +.btn-warning { + background-color: var(--tn-yellow); + border-color: var(--tn-yellow); + color: var(--tn-bg-dark); + &:hover { background-color: #f0c070; border-color: #f0c070; color: var(--tn-bg-dark); } +} + +.btn-success { + background-color: var(--tn-green); + border-color: var(--tn-green); + color: var(--tn-bg-dark); + &:hover { background-color: #acd87a; border-color: #acd87a; color: var(--tn-bg-dark); } +} + +.btn-danger { + background-color: var(--tn-red); + border-color: var(--tn-red); + color: var(--tn-bg-dark); + &:hover { background-color: #f98a9e; border-color: #f98a9e; color: var(--tn-bg-dark); } +} + +.btn-outline-secondary { + border-color: var(--tn-border); + color: var(--tn-fg-dim); + background-color: transparent; + &:hover { background-color: var(--tn-bg-card); border-color: var(--tn-blue); color: var(--tn-blue); box-shadow: 0 0 12px rgba(122,162,247,0.15); } +} + +.btn-outline-primary { + border-color: var(--tn-blue); + color: var(--tn-blue); + background-color: transparent; + &:hover { background-color: rgba(122,162,247,0.1); color: var(--tn-blue); } +} + +.btn-close { + filter: invert(1) brightness(0.6); + &:hover { filter: invert(1) brightness(1); } +} + +// Forms +.form-select, .form-control { + background-color: var(--tn-bg-dark); + border: 1px solid var(--tn-border); + color: var(--tn-fg); + font-family: 'JetBrains Mono', monospace; + border-radius: 4px; + + &:focus { + background-color: var(--tn-bg-dark); + border-color: var(--tn-blue); + color: var(--tn-fg); + box-shadow: 0 0 0 2px rgba(122,162,247,0.15); + } + + option { background-color: var(--tn-bg-card); } +} + +.form-check-input { + background-color: var(--tn-bg-dark); + border-color: var(--tn-border); + &:checked { background-color: var(--tn-blue); border-color: var(--tn-blue); } + &:focus { box-shadow: 0 0 0 2px rgba(122,162,247,0.15); border-color: var(--tn-blue); } +} + +.form-check-label { color: var(--tn-fg-dim); } + +.input-group-text { + background-color: var(--tn-bg-dark); + border-color: var(--tn-border); + color: var(--tn-muted); + font-family: 'JetBrains Mono', monospace; +} + +// Alerts +.alert { + background-color: var(--tn-bg-card); + border-color: var(--tn-border); + color: var(--tn-fg); + + &.alert-dark { + background-color: var(--tn-bg-dark); + border-color: var(--tn-border); + color: var(--tn-fg-dim); + } +} + +// Badges +.badge { + font-family: 'JetBrains Mono', monospace; + font-weight: 400; + + &.bg-success { background-color: var(--tn-green) !important; color: var(--tn-bg-dark); } + &.bg-danger { background-color: var(--tn-red) !important; color: var(--tn-bg-dark); } + &.bg-warning { background-color: var(--tn-yellow) !important; color: var(--tn-bg-dark); } + &.bg-secondary { background-color: var(--tn-muted) !important; color: var(--tn-fg); } + &.bg-light { background-color: var(--tn-bg-dark) !important; color: var(--tn-fg) !important; border-color: var(--tn-border) !important; } +} + +// Modal +.modal-content { + background-color: var(--tn-bg-card); + border: 1px solid var(--tn-border); + border-radius: 8px; + color: var(--tn-fg); +} + +.modal-header { + background-color: var(--tn-bg-dark); + border-bottom: 1px solid var(--tn-border); + color: var(--tn-blue); +} + +.modal-footer { + background-color: var(--tn-bg-dark); + border-top: 1px solid var(--tn-border); + color: var(--tn-muted); +} + +// List group +.list-group-item { + background-color: var(--tn-bg-card); + border-color: var(--tn-border); + color: var(--tn-fg); + + &.task { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 12px; + + .file { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + min-width: 0; + + a { min-width: 0; } + } + } +} + +// Progress bar +.progress { + background-color: var(--tn-bg-dark); + border: 1px solid var(--tn-border); + + .progress-bar { + background-color: var(--tn-blue); + } +} + +// Spinners .loader { width: 48px; height: 48px; - border: 5px solid #777; - border-bottom-color: transparent; + border: 5px solid var(--tn-border); + border-bottom-color: var(--tn-blue); border-radius: 50%; display: inline-block; box-sizing: border-box; @@ -22,92 +313,677 @@ } @keyframes rotation { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } -} - -/* END LOADING SPINNER */ - -.transcodate-form-container { - margin-top: 20px; - display: flex; - flex-wrap: wrap; - flex-direction: row; - gap: 10px; + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } } -.transcodate-form-container > .form-control { - display: flex; - flex-wrap: nowrap; - flex-direction: row; - justify-content: flex-start; - gap: 10px; +.loading-spinner-container { + width: 100%; + margin: 50px auto; + text-align: center; +} + +.sm-spinner { width: 18px; height: 18px; border-width: 3px; } + +// Nav brand +.nav-brand { + display: flex; align-items: center; + line-height: 1; } -.transcodate-form-container > .form-control > strong { - white-space: nowrap; - display: inline-block; - width: 100px; +.nav-icon-btn { + background-color: var(--tn-bg-card); + border: 1px solid var(--tn-border); + color: var(--tn-muted); + border-radius: 50% !important; + width: 52px; + height: 52px; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.2s, box-shadow 0.2s, color 0.2s; + &:hover { + border-color: var(--tn-blue); + color: var(--tn-blue); + box-shadow: 0 0 12px rgba(122,162,247,0.25); + } } -.crf-range { - margin-left: 5px; +// Header nav button +.open-transcoding-tasks { + background-color: var(--tn-bg-card); + border: 1px solid var(--tn-border); + color: var(--tn-muted); + border-radius: 50% !important; + width: 52px; + height: 52px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + transition: border-color 0.2s, box-shadow 0.2s, color 0.2s; + + &:hover { + border-color: var(--tn-blue); + color: var(--tn-blue); + box-shadow: 0 0 12px rgba(122,162,247,0.25); + } + + .total-tasks:not(:empty) { + position: absolute; + top: -6px; + right: -6px; + background-color: var(--tn-yellow); + color: var(--tn-bg-dark); + font-size: 0.75rem; + font-weight: 700; + min-width: 20px; + height: 20px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 5px; + line-height: 1; + } } +// Transcoding form +.transcodate-form-container { + margin-top: 20px; + display: flex; + flex-wrap: wrap; + flex-direction: row; + gap: 10px; + + > .form-control { + display: flex; + flex-wrap: nowrap; + flex-direction: row; + justify-content: flex-start; + gap: 10px; + align-items: center; + background: transparent; + border: none; + padding: 0; + + > strong { + white-space: nowrap; + display: inline-block; + width: 110px; + color: var(--tn-muted); + font-weight: 400; + font-size: 0.875rem; + letter-spacing: 0.02em; + } + } + + .arrow-to { color: var(--tn-muted); } + + .btns { width: 100%; } +} + +.crf-range { margin-left: 5px; color: var(--tn-muted); } + #progress { margin-top: 20px; margin-bottom: 0; + font-size: 0.85rem; + color: var(--tn-fg-dim); } -.progress-circle { -/* transform: rotate(-90deg);*/ +// Modal empty state +.empty-tasks-message { color: var(--tn-fg-dim); } + +// Media list file details +.filename a { + color: var(--tn-fg); + font-weight: 400; + &:hover { color: var(--tn-blue); } } -.circle-bg { - stroke-dasharray: 100, 100; +.filepath { + font-size: 0.78rem; + color: var(--tn-muted); + margin-bottom: 6px; } -.circle-progress { - transition: stroke-dasharray 0.3s ease; -} - -.list-group-item.task { +.filedetails { display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - gap: 12px; + flex-direction: column; + gap: 3px; } -.list-group-item.task .file { +.streams-section { display: flex; - flex-direction: row; + align-items: flex-start; gap: 8px; +} + +.stream-label { + font-size: 0.7rem; + color: var(--tn-muted); + letter-spacing: 0.06em; + text-transform: uppercase; + min-width: 36px; + padding-top: 3px; + flex-shrink: 0; +} + +.stream-rows { + display: flex; + flex-direction: column; + gap: 3px; +} + +.stream-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; +} + +.stream-bitrate { + font-size: 0.75rem; + color: var(--tn-muted); +} + +.stream-title { + font-size: 0.75rem; + color: var(--tn-fg-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 380px; +} + +// Language badges +.lang-badge { + font-size: 0.68rem; + letter-spacing: 0.04em; + text-transform: uppercase; + min-width: 30px; + text-align: center; +} +.lang-ukr { background-color: var(--tn-cyan) !important; color: var(--tn-bg-dark); } +.lang-eng { background-color: var(--tn-blue) !important; color: var(--tn-bg-dark); } +.lang-rus { background-color: var(--tn-red) !important; color: var(--tn-bg-dark); } +.lang-jpn { background-color: var(--tn-purple) !important; color: var(--tn-bg-dark); } +.lang-deu, +.lang-fra, +.lang-spa, +.lang-ita, +.lang-pol { background-color: var(--tn-green) !important; color: var(--tn-bg-dark); } +.lang-und { background-color: var(--tn-muted) !important; color: var(--tn-fg); } + +// Footer stats +.footer { + border-top: 1px solid var(--tn-border); + padding: 16px 0 20px; +} + +.footer-stats { + display: flex; + align-items: center; + gap: 20px; + flex-wrap: wrap; +} + +.footer-stat { + display: flex; + align-items: center; + gap: 6px; +} + +.footer-stat-icon { + color: var(--tn-muted); + font-size: 0.95rem; +} + +.footer-stat-value { + color: var(--tn-blue); + font-size: 0.9rem; + letter-spacing: 0.02em; +} + +.footer-stat-label { + color: var(--tn-muted); + font-size: 0.78rem; + letter-spacing: 0.03em; +} + +.footer-stat-divider { + width: 1px; + height: 16px; + background-color: var(--tn-border); +} + +// Nav logo +.nav-logo { + height: 32px; + width: auto; + display: block; + transition: opacity 0.2s, filter 0.2s; + filter: brightness(0.85); + + .nav-brand:hover & { + opacity: 1; + filter: brightness(1) drop-shadow(0 0 8px rgba(122, 162, 247, 0.5)); + } +} + +// Auth logo +.auth-logo { + max-height: 100px; + width: auto; + display: block; + margin: 0 auto; +} + +// Nav user info +.nav-user-divider { + width: 1px; + height: 24px; + background-color: var(--tn-border); + margin: 0 4px; + flex-shrink: 0; +} + +.nav-username { + color: var(--tn-muted); + font-size: 0.78rem; + letter-spacing: 0.04em; + white-space: nowrap; + line-height: 1; +} + +// Auth pages +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 1rem; +} + +.auth-card { + background-color: var(--tn-bg-card); + border: 1px solid var(--tn-border); + border-radius: 10px; + padding: 2.5rem 2rem; + width: 100%; + max-width: 380px; +} + +.auth-logo-space { + height: 100px; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + justify-content: center; +} + +.auth-title { + color: var(--tn-blue); + font-size: 1.15rem; + letter-spacing: 0.06em; + font-weight: 400; + margin-bottom: 0.25rem; + text-align: center; +} + +.auth-subtitle { + color: var(--tn-muted); + font-size: 0.78rem; + text-align: center; + margin-bottom: 1.5rem; + letter-spacing: 0.03em; +} + +.auth-field { + margin-bottom: 1.1rem; + + label { + display: block; + color: var(--tn-muted); + font-size: 0.78rem; + letter-spacing: 0.04em; + margin-bottom: 0.3rem; + } +} + +.auth-input-wrap { + position: relative; + display: flex; align-items: center; } -.sm-spinner { - width: 18px; - height: 18px; - border-width: 3px; -} +.auth-input { + width: 100%; + background-color: var(--tn-bg-dark); + border: 1px solid var(--tn-border); + border-radius: 6px; + color: var(--tn-fg); + font-family: 'JetBrains Mono', monospace; + font-size: 0.9rem; + padding: 0.55rem 2.4rem 0.55rem 0.75rem; + outline: none; + transition: border-color 0.2s; -.btn { - .border-spinner { - margin-right: 2px; - position: relative; - top: 1px; + &:focus { + border-color: var(--tn-blue); } - &.inprogress { - display: inline-flex; - gap: 6px; + // standalone input (no wrap) + &:not(.auth-input-wrap .auth-input) { + padding-right: 0.75rem; + } +} + +.auth-eye-btn { + position: absolute; + right: 0.55rem; + background: none; + border: none; + color: var(--tn-muted); + cursor: pointer; + padding: 0; + line-height: 1; + font-size: 1rem; + + &:hover { color: var(--tn-fg-dim); } +} + +.auth-btn { + width: 100%; + margin-top: 0.5rem; + padding: 0.6rem; + background-color: var(--tn-blue); + color: var(--tn-bg-dark); + border: none; + border-radius: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.9rem; + letter-spacing: 0.04em; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { background-color: var(--tn-cyan); } + &:disabled { opacity: 0.5; cursor: default; } +} + +.auth-error { + background-color: rgba(247, 118, 142, 0.12); + border: 1px solid var(--tn-red); + border-radius: 6px; + color: var(--tn-red); + font-size: 0.82rem; + padding: 0.5rem 0.75rem; + margin-bottom: 1rem; +} + +.auth-success { + background-color: rgba(158, 206, 106, 0.12); + border: 1px solid var(--tn-green); + border-radius: 6px; + color: var(--tn-green); + font-size: 0.82rem; + padding: 0.5rem 0.75rem; + margin-bottom: 1rem; +} + +// Scanning message +.scaning-process-container { + font-size: 0.85rem; + color: var(--tn-muted); + background-color: var(--tn-bg-dark); + border-color: var(--tn-border); + word-break: break-all; + overflow-wrap: anywhere; +} + +// ── Notification bell badge ──────────────────────────────────────────────────── +.open-notifications { + position: relative; + + .notif-badge:not(:empty) { + position: absolute; + top: -6px; + right: -6px; + background-color: var(--tn-red); + color: var(--tn-bg-dark); + font-size: 0.7rem; + font-weight: 700; + min-width: 18px; + height: 18px; + border-radius: 9px; + display: flex; align-items: center; + justify-content: center; + padding: 0 4px; + line-height: 1; } -} \ No newline at end of file +} + +// ── Notification toast ───────────────────────────────────────────────────────── +.notif-toast { + background-color: var(--tn-bg-card); + border: 1px solid var(--tn-border); + border-left: 3px solid var(--notif-color, var(--tn-blue)); + border-radius: 6px; + padding: 10px 12px; + margin-bottom: 8px; + opacity: 0; + transform: translateX(16px); + transition: opacity 0.25s, transform 0.25s; + max-width: 360px; + font-size: 0.83rem; + + &.show { + opacity: 1; + transform: translateX(0); + } + + .notif-toast-header { + display: flex; + align-items: center; + gap: 7px; + } + + .notif-toast-icon { + color: var(--notif-color, var(--tn-blue)); + flex-shrink: 0; + font-size: 0.9rem; + } + + .notif-toast-title { + color: var(--tn-fg); + font-weight: 400; + letter-spacing: 0.02em; + flex-grow: 1; + } + + .notif-toast-msg { + margin-top: 6px; + color: var(--tn-muted); + font-size: 0.78rem; + max-height: 80px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + word-break: break-all; + overflow-wrap: anywhere; + } +} + +// ── Transcoding tasks offcanvas ──────────────────────────────────────────────── +.transcoding-offcanvas { + background-color: var(--tn-bg-card); + border-left: 1px solid var(--tn-border); + width: 480px !important; + font-family: 'JetBrains Mono', monospace; + + .offcanvas-header { + background-color: var(--tn-bg-dark); + border-bottom: 1px solid var(--tn-border); + padding: 16px 20px; + + .offcanvas-title { + color: var(--tn-blue); + font-size: 0.95rem; + letter-spacing: 0.04em; + font-weight: 400; + } + } + + .transcoding-toolbar { + border-bottom: 1px solid var(--tn-border); + background-color: var(--tn-bg-dark); + flex-shrink: 0; + min-height: 40px; + } + + .transcoding-toolbar-count { + color: var(--tn-muted); + font-size: 0.8rem; + letter-spacing: 0.03em; + + .total-tasks { color: var(--tn-yellow); } + } + + .transcoding-tasks-container { + .list-group-item.task { + background-color: transparent; + border-color: rgba(41, 46, 66, 0.6); + padding: 14px 20px; + } + } +} + +// ── Notification offcanvas ───────────────────────────────────────────────────── +.notifications-offcanvas { + background-color: var(--tn-bg-card); + border-left: 1px solid var(--tn-border); + width: 480px !important; + font-family: 'JetBrains Mono', monospace; + + .offcanvas-header { + background-color: var(--tn-bg-dark); + border-bottom: 1px solid var(--tn-border); + padding: 16px 20px; + + .offcanvas-title { + color: var(--tn-blue); + font-size: 0.95rem; + letter-spacing: 0.04em; + font-weight: 400; + } + } + + .notif-toolbar { + border-bottom: 1px solid var(--tn-border); + background-color: var(--tn-bg-dark); + flex-shrink: 0; + padding: 8px 16px; + + .btn { + font-size: 0.8rem; + padding: 4px 12px; + } + } +} + +.notif-list { + padding: 8px 0; +} + +.notif-item { + padding: 14px 20px; + border-bottom: 1px solid rgba(41, 46, 66, 0.5); + transition: background-color 0.15s, opacity 0.25s; + border-left: 3px solid transparent; + + &.is-unread { + border-left-color: var(--notif-color, var(--tn-blue)); + background-color: rgba(122, 162, 247, 0.04); + } + + &.removing { opacity: 0; } + + &:hover { background-color: rgba(122, 162, 247, 0.05); } + + .notif-item-header { + display: flex; + align-items: flex-start; + gap: 10px; + } + + .notif-item-icon { + color: var(--notif-color, var(--tn-blue)); + font-size: 1rem; + flex-shrink: 0; + margin-top: 2px; + } + + .notif-item-meta { + flex-grow: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; + } + + .notif-item-title { + color: var(--tn-fg); + font-size: 0.95rem; + letter-spacing: 0.02em; + word-break: break-word; + } + + .notif-item-time { + color: var(--tn-muted); + font-size: 0.8rem; + } + + .notif-delete-btn { + background: none; + border: none; + color: var(--tn-muted); + padding: 0 2px; + line-height: 1; + cursor: pointer; + flex-shrink: 0; + font-size: 1.1rem; + opacity: 0; + transition: color 0.15s, opacity 0.15s; + + &:hover { color: var(--tn-red); } + } + + &:hover .notif-delete-btn { opacity: 1; } + + .notif-item-msg { + margin-top: 8px; + color: var(--tn-muted); + font-size: 0.86rem; + word-break: break-all; + overflow-wrap: anywhere; + line-height: 1.55; + } + + .notif-expand-btn { + background: none; + border: none; + color: var(--tn-blue); + font-family: 'JetBrains Mono', monospace; + font-size: 0.76rem; + cursor: pointer; + padding: 3px 0; + margin-top: 3px; + + &:hover { color: var(--tn-cyan); } + } +} diff --git a/static/css/main.css b/static/css/main.css index 18094ca..48586fd 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1,2 +1,2 @@ -a{text-decoration:none}.loading-spinner-container{width:100%;margin:50px auto;text-align:center}.loader{width:48px;height:48px;border:5px solid #777;border-bottom-color:transparent;border-radius:50%;display:inline-block;box-sizing:border-box;animation:rotation 1s linear infinite}@keyframes rotation{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}.transcodate-form-container{margin-top:20px;display:flex;flex-wrap:wrap;flex-direction:row;gap:10px}.transcodate-form-container>.form-control{display:flex;flex-wrap:nowrap;flex-direction:row;justify-content:flex-start;gap:10px;align-items:center}.transcodate-form-container>.form-control>strong{white-space:nowrap;display:inline-block;width:100px}.crf-range{margin-left:5px}#progress{margin-top:20px;margin-bottom:0}.circle-bg{stroke-dasharray:100,100}.circle-progress{transition:stroke-dasharray .3s ease}.list-group-item.task{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:12px}.list-group-item.task .file{display:flex;flex-direction:row;gap:8px;align-items:center}.sm-spinner{width:18px;height:18px;border-width:3px}.btn .border-spinner{margin-right:2px;position:relative;top:1px}.btn.inprogress{display:inline-flex;gap:6px;align-items:center}.component.media-list .do-rescan-media-lib .spinner-border{display:none}.component.media-list .do-rescan-media-lib.inprogress .spinner-border{display:block}.component.errors-center{position:fixed;bottom:0;width:100%;max-width:500px;padding:0 20px;box-sizing:border-box;left:calc(50% - 250px);z-index:100} +:root{--tn-bg:#1a1b26;--tn-bg-dark:#16161e;--tn-bg-card:#1f2335;--tn-border:#292e42;--tn-fg:#c0caf5;--tn-fg-dim:#a9b1d6;--tn-muted:#565f89;--tn-blue:#7aa2f7;--tn-cyan:#7dcfff;--tn-purple:#bb9af7;--tn-green:#9ece6a;--tn-yellow:#e0af68;--tn-red:#f7768e}.text-muted{color:var(--tn-fg-dim)!important}.file a,.filename a,.filepath,.modal-body strong,.stream-title,h1,h2,h3,h4,h5,h6,td{word-break:break-word;overflow-wrap:anywhere}*{box-sizing:border-box}body{font-family:"JetBrains Mono",monospace;background-color:var(--tn-bg);color:var(--tn-fg);font-size:1rem}a{text-decoration:none;color:var(--tn-blue)}a:hover{color:var(--tn-cyan)}h1,h2,h3,h4,h5,h6{color:var(--tn-blue);letter-spacing:.04em;font-weight:400}.card{background-color:var(--tn-bg-card);border:1px solid var(--tn-border);border-radius:8px;transition:border-color .2s,box-shadow .2s}.card .card-header{background-color:var(--tn-bg-dark);border-bottom:1px solid var(--tn-border);color:var(--tn-blue);letter-spacing:.04em}.card .card-body{color:var(--tn-fg)}.table{color:var(--tn-fg);border-color:var(--tn-border)}.table thead th{color:var(--tn-blue);font-weight:400;border-color:var(--tn-border);letter-spacing:.04em;background-color:var(--tn-bg-dark)}.table td{color:var(--tn-fg);border-color:var(--tn-border);padding:6px 10px}.table.table-striped>tbody>tr:nth-of-type(odd)>*{background-color:rgba(41,46,66,.4);color:var(--tn-fg)}.table.table-hover>tbody>tr:hover>*{background-color:rgba(122,162,247,.08);color:var(--tn-fg)}.dataTables_wrapper{color:var(--tn-fg)}.dataTables_wrapper .dataTables_filter input,.dataTables_wrapper .dataTables_length select{background-color:var(--tn-bg-dark);border:1px solid var(--tn-border);color:var(--tn-fg);border-radius:4px;padding:2px 8px;font-family:"JetBrains Mono",monospace;outline:0}.dataTables_wrapper .dataTables_filter input:focus,.dataTables_wrapper .dataTables_length select:focus{border-color:var(--tn-blue)}.dataTables_wrapper .dataTables_filter label,.dataTables_wrapper .dataTables_info,.dataTables_wrapper .dataTables_length label{color:var(--tn-muted)}.dataTables_wrapper .dataTables_paginate .paginate_button{color:var(--tn-muted)!important;border-radius:4px!important}.dataTables_wrapper .dataTables_paginate .paginate_button:hover{background:var(--tn-bg-card)!important;color:var(--tn-blue)!important;border-color:var(--tn-border)!important}.dataTables_wrapper .dataTables_paginate .paginate_button.current{background:var(--tn-bg-card)!important;color:var(--tn-blue)!important;border-color:var(--tn-border)!important}.dataTables_wrapper .dataTables_paginate .paginate_button.disabled{color:var(--tn-border)!important}.btn{font-family:"JetBrains Mono",monospace;border-radius:4px;letter-spacing:.02em}.btn.border-spinner{margin-right:2px;position:relative;top:1px}.btn.inprogress{display:inline-flex;gap:6px;align-items:center}.btn-primary{background-color:var(--tn-blue);border-color:var(--tn-blue);color:var(--tn-bg-dark)}.btn-primary:hover{background-color:var(--tn-cyan);border-color:var(--tn-cyan);color:var(--tn-bg-dark)}.btn-warning{background-color:var(--tn-yellow);border-color:var(--tn-yellow);color:var(--tn-bg-dark)}.btn-warning:hover{background-color:#f0c070;border-color:#f0c070;color:var(--tn-bg-dark)}.btn-success{background-color:var(--tn-green);border-color:var(--tn-green);color:var(--tn-bg-dark)}.btn-success:hover{background-color:#acd87a;border-color:#acd87a;color:var(--tn-bg-dark)}.btn-danger{background-color:var(--tn-red);border-color:var(--tn-red);color:var(--tn-bg-dark)}.btn-danger:hover{background-color:#f98a9e;border-color:#f98a9e;color:var(--tn-bg-dark)}.btn-outline-secondary{border-color:var(--tn-border);color:var(--tn-fg-dim);background-color:transparent}.btn-outline-secondary:hover{background-color:var(--tn-bg-card);border-color:var(--tn-blue);color:var(--tn-blue);box-shadow:0 0 12px rgba(122,162,247,.15)}.btn-outline-primary{border-color:var(--tn-blue);color:var(--tn-blue);background-color:transparent}.btn-outline-primary:hover{background-color:rgba(122,162,247,.1);color:var(--tn-blue)}.btn-close{filter:invert(1) brightness(.6)}.btn-close:hover{filter:invert(1) brightness(1)}.form-control,.form-select{background-color:var(--tn-bg-dark);border:1px solid var(--tn-border);color:var(--tn-fg);font-family:"JetBrains Mono",monospace;border-radius:4px}.form-control:focus,.form-select:focus{background-color:var(--tn-bg-dark);border-color:var(--tn-blue);color:var(--tn-fg);box-shadow:0 0 0 2px rgba(122,162,247,.15)}.form-control option,.form-select option{background-color:var(--tn-bg-card)}.form-check-input{background-color:var(--tn-bg-dark);border-color:var(--tn-border)}.form-check-input:checked{background-color:var(--tn-blue);border-color:var(--tn-blue)}.form-check-input:focus{box-shadow:0 0 0 2px rgba(122,162,247,.15);border-color:var(--tn-blue)}.form-check-label{color:var(--tn-fg-dim)}.input-group-text{background-color:var(--tn-bg-dark);border-color:var(--tn-border);color:var(--tn-muted);font-family:"JetBrains Mono",monospace}.alert{background-color:var(--tn-bg-card);border-color:var(--tn-border);color:var(--tn-fg)}.alert.alert-dark{background-color:var(--tn-bg-dark);border-color:var(--tn-border);color:var(--tn-fg-dim)}.badge{font-family:"JetBrains Mono",monospace;font-weight:400}.badge.bg-success{background-color:var(--tn-green)!important;color:var(--tn-bg-dark)}.badge.bg-danger{background-color:var(--tn-red)!important;color:var(--tn-bg-dark)}.badge.bg-warning{background-color:var(--tn-yellow)!important;color:var(--tn-bg-dark)}.badge.bg-secondary{background-color:var(--tn-muted)!important;color:var(--tn-fg)}.badge.bg-light{background-color:var(--tn-bg-dark)!important;color:var(--tn-fg)!important;border-color:var(--tn-border)!important}.modal-content{background-color:var(--tn-bg-card);border:1px solid var(--tn-border);border-radius:8px;color:var(--tn-fg)}.modal-header{background-color:var(--tn-bg-dark);border-bottom:1px solid var(--tn-border);color:var(--tn-blue)}.modal-footer{background-color:var(--tn-bg-dark);border-top:1px solid var(--tn-border);color:var(--tn-muted)}.list-group-item{background-color:var(--tn-bg-card);border-color:var(--tn-border);color:var(--tn-fg)}.list-group-item.task{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:12px}.list-group-item.task .file{display:flex;flex-direction:row;gap:8px;align-items:center;min-width:0}.list-group-item.task .file a{min-width:0}.progress{background-color:var(--tn-bg-dark);border:1px solid var(--tn-border)}.progress .progress-bar{background-color:var(--tn-blue)}.loader{width:48px;height:48px;border:5px solid var(--tn-border);border-bottom-color:var(--tn-blue);border-radius:50%;display:inline-block;box-sizing:border-box;animation:rotation 1s linear infinite}@keyframes rotation{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}.loading-spinner-container{width:100%;margin:50px auto;text-align:center}.sm-spinner{width:18px;height:18px;border-width:3px}.nav-brand{display:flex;align-items:center;line-height:1}.nav-icon-btn{background-color:var(--tn-bg-card);border:1px solid var(--tn-border);color:var(--tn-muted);border-radius:50%!important;width:52px;height:52px;display:flex;align-items:center;justify-content:center;transition:border-color .2s,box-shadow .2s,color .2s}.nav-icon-btn:hover{border-color:var(--tn-blue);color:var(--tn-blue);box-shadow:0 0 12px rgba(122,162,247,.25)}.open-transcoding-tasks{background-color:var(--tn-bg-card);border:1px solid var(--tn-border);color:var(--tn-muted);border-radius:50%!important;width:52px;height:52px;display:flex;align-items:center;justify-content:center;position:relative;transition:border-color .2s,box-shadow .2s,color .2s}.open-transcoding-tasks:hover{border-color:var(--tn-blue);color:var(--tn-blue);box-shadow:0 0 12px rgba(122,162,247,.25)}.open-transcoding-tasks .total-tasks:not(:empty){position:absolute;top:-6px;right:-6px;background-color:var(--tn-yellow);color:var(--tn-bg-dark);font-size:.75rem;font-weight:700;min-width:20px;height:20px;border-radius:10px;display:flex;align-items:center;justify-content:center;padding:0 5px;line-height:1}.transcodate-form-container{margin-top:20px;display:flex;flex-wrap:wrap;flex-direction:row;gap:10px}.transcodate-form-container>.form-control{display:flex;flex-wrap:nowrap;flex-direction:row;justify-content:flex-start;gap:10px;align-items:center;background:0 0;border:none;padding:0}.transcodate-form-container>.form-control>strong{white-space:nowrap;display:inline-block;width:110px;color:var(--tn-muted);font-weight:400;font-size:.875rem;letter-spacing:.02em}.transcodate-form-container .arrow-to{color:var(--tn-muted)}.transcodate-form-container .btns{width:100%}.crf-range{margin-left:5px;color:var(--tn-muted)}#progress{margin-top:20px;margin-bottom:0;font-size:.85rem;color:var(--tn-fg-dim)}.empty-tasks-message{color:var(--tn-fg-dim)}.filename a{color:var(--tn-fg);font-weight:400}.filename a:hover{color:var(--tn-blue)}.filepath{font-size:.78rem;color:var(--tn-muted);margin-bottom:6px}.filedetails{display:flex;flex-direction:column;gap:3px}.streams-section{display:flex;align-items:flex-start;gap:8px}.stream-label{font-size:.7rem;color:var(--tn-muted);letter-spacing:.06em;text-transform:uppercase;min-width:36px;padding-top:3px;flex-shrink:0}.stream-rows{display:flex;flex-direction:column;gap:3px}.stream-row{display:flex;align-items:center;flex-wrap:wrap;gap:4px}.stream-bitrate{font-size:.75rem;color:var(--tn-muted)}.stream-title{font-size:.75rem;color:var(--tn-fg-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:380px}.lang-badge{font-size:.68rem;letter-spacing:.04em;text-transform:uppercase;min-width:30px;text-align:center}.lang-ukr{background-color:var(--tn-cyan)!important;color:var(--tn-bg-dark)}.lang-eng{background-color:var(--tn-blue)!important;color:var(--tn-bg-dark)}.lang-rus{background-color:var(--tn-red)!important;color:var(--tn-bg-dark)}.lang-jpn{background-color:var(--tn-purple)!important;color:var(--tn-bg-dark)}.lang-deu,.lang-fra,.lang-ita,.lang-pol,.lang-spa{background-color:var(--tn-green)!important;color:var(--tn-bg-dark)}.lang-und{background-color:var(--tn-muted)!important;color:var(--tn-fg)}.footer{border-top:1px solid var(--tn-border);padding:16px 0 20px}.footer-stats{display:flex;align-items:center;gap:20px;flex-wrap:wrap}.footer-stat{display:flex;align-items:center;gap:6px}.footer-stat-icon{color:var(--tn-muted);font-size:.95rem}.footer-stat-value{color:var(--tn-blue);font-size:.9rem;letter-spacing:.02em}.footer-stat-label{color:var(--tn-muted);font-size:.78rem;letter-spacing:.03em}.footer-stat-divider{width:1px;height:16px;background-color:var(--tn-border)}.nav-logo{height:32px;width:auto;display:block;transition:opacity .2s,filter .2s;filter:brightness(.85)}.nav-brand:hover .nav-logo{opacity:1;filter:brightness(1) drop-shadow(0 0 8px rgba(122, 162, 247, .5))}.auth-logo{max-height:100px;width:auto;display:block;margin:0 auto}.nav-user-divider{width:1px;height:24px;background-color:var(--tn-border);margin:0 4px;flex-shrink:0}.nav-username{color:var(--tn-muted);font-size:.78rem;letter-spacing:.04em;white-space:nowrap;line-height:1}.auth-page{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:2rem 1rem}.auth-card{background-color:var(--tn-bg-card);border:1px solid var(--tn-border);border-radius:10px;padding:2.5rem 2rem;width:100%;max-width:380px}.auth-logo-space{height:100px;margin-bottom:1.5rem;display:flex;align-items:center;justify-content:center}.auth-title{color:var(--tn-blue);font-size:1.15rem;letter-spacing:.06em;font-weight:400;margin-bottom:.25rem;text-align:center}.auth-subtitle{color:var(--tn-muted);font-size:.78rem;text-align:center;margin-bottom:1.5rem;letter-spacing:.03em}.auth-field{margin-bottom:1.1rem}.auth-field label{display:block;color:var(--tn-muted);font-size:.78rem;letter-spacing:.04em;margin-bottom:.3rem}.auth-input-wrap{position:relative;display:flex;align-items:center}.auth-input{width:100%;background-color:var(--tn-bg-dark);border:1px solid var(--tn-border);border-radius:6px;color:var(--tn-fg);font-family:"JetBrains Mono",monospace;font-size:.9rem;padding:.55rem 2.4rem .55rem .75rem;outline:0;transition:border-color .2s}.auth-input:focus{border-color:var(--tn-blue)}.auth-input:not(.auth-input-wrap.auth-input){padding-right:.75rem}.auth-eye-btn{position:absolute;right:.55rem;background:0 0;border:none;color:var(--tn-muted);cursor:pointer;padding:0;line-height:1;font-size:1rem}.auth-eye-btn:hover{color:var(--tn-fg-dim)}.auth-btn{width:100%;margin-top:.5rem;padding:.6rem;background-color:var(--tn-blue);color:var(--tn-bg-dark);border:none;border-radius:6px;font-family:"JetBrains Mono",monospace;font-size:.9rem;letter-spacing:.04em;cursor:pointer;transition:background-color .2s}.auth-btn:hover{background-color:var(--tn-cyan)}.auth-btn:disabled{opacity:.5;cursor:default}.auth-error{background-color:rgba(247,118,142,.12);border:1px solid var(--tn-red);border-radius:6px;color:var(--tn-red);font-size:.82rem;padding:.5rem .75rem;margin-bottom:1rem}.auth-success{background-color:rgba(158,206,106,.12);border:1px solid var(--tn-green);border-radius:6px;color:var(--tn-green);font-size:.82rem;padding:.5rem .75rem;margin-bottom:1rem}.scaning-process-container{font-size:.85rem;color:var(--tn-muted);background-color:var(--tn-bg-dark);border-color:var(--tn-border);word-break:break-all;overflow-wrap:anywhere}.open-notifications{position:relative}.open-notifications .notif-badge:not(:empty){position:absolute;top:-6px;right:-6px;background-color:var(--tn-red);color:var(--tn-bg-dark);font-size:.7rem;font-weight:700;min-width:18px;height:18px;border-radius:9px;display:flex;align-items:center;justify-content:center;padding:0 4px;line-height:1}.notif-toast{background-color:var(--tn-bg-card);border:1px solid var(--tn-border);border-left:3px solid var(--notif-color,var(--tn-blue));border-radius:6px;padding:10px 12px;margin-bottom:8px;opacity:0;transform:translateX(16px);transition:opacity .25s,transform .25s;max-width:360px;font-size:.83rem}.notif-toast.show{opacity:1;transform:translateX(0)}.notif-toast .notif-toast-header{display:flex;align-items:center;gap:7px}.notif-toast .notif-toast-icon{color:var(--notif-color,var(--tn-blue));flex-shrink:0;font-size:.9rem}.notif-toast .notif-toast-title{color:var(--tn-fg);font-weight:400;letter-spacing:.02em;flex-grow:1}.notif-toast .notif-toast-msg{margin-top:6px;color:var(--tn-muted);font-size:.78rem;max-height:80px;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;word-break:break-all;overflow-wrap:anywhere}.transcoding-offcanvas{background-color:var(--tn-bg-card);border-left:1px solid var(--tn-border);width:480px!important;font-family:"JetBrains Mono",monospace}.transcoding-offcanvas .offcanvas-header{background-color:var(--tn-bg-dark);border-bottom:1px solid var(--tn-border);padding:16px 20px}.transcoding-offcanvas .offcanvas-header .offcanvas-title{color:var(--tn-blue);font-size:.95rem;letter-spacing:.04em;font-weight:400}.transcoding-offcanvas .transcoding-toolbar{border-bottom:1px solid var(--tn-border);background-color:var(--tn-bg-dark);flex-shrink:0;min-height:40px}.transcoding-offcanvas .transcoding-toolbar-count{color:var(--tn-muted);font-size:.8rem;letter-spacing:.03em}.transcoding-offcanvas .transcoding-toolbar-count .total-tasks{color:var(--tn-yellow)}.transcoding-offcanvas .transcoding-tasks-container .list-group-item.task{background-color:transparent;border-color:rgba(41,46,66,.6);padding:14px 20px}.notifications-offcanvas{background-color:var(--tn-bg-card);border-left:1px solid var(--tn-border);width:480px!important;font-family:"JetBrains Mono",monospace}.notifications-offcanvas .offcanvas-header{background-color:var(--tn-bg-dark);border-bottom:1px solid var(--tn-border);padding:16px 20px}.notifications-offcanvas .offcanvas-header .offcanvas-title{color:var(--tn-blue);font-size:.95rem;letter-spacing:.04em;font-weight:400}.notifications-offcanvas .notif-toolbar{border-bottom:1px solid var(--tn-border);background-color:var(--tn-bg-dark);flex-shrink:0;padding:8px 16px}.notifications-offcanvas .notif-toolbar .btn{font-size:.8rem;padding:4px 12px}.notif-list{padding:8px 0}.notif-item{padding:14px 20px;border-bottom:1px solid rgba(41,46,66,.5);transition:background-color .15s,opacity .25s;border-left:3px solid transparent}.notif-item.is-unread{border-left-color:var(--notif-color,var(--tn-blue));background-color:rgba(122,162,247,.04)}.notif-item.removing{opacity:0}.notif-item:hover{background-color:rgba(122,162,247,.05)}.notif-item .notif-item-header{display:flex;align-items:flex-start;gap:10px}.notif-item .notif-item-icon{color:var(--notif-color,var(--tn-blue));font-size:1rem;flex-shrink:0;margin-top:2px}.notif-item .notif-item-meta{flex-grow:1;min-width:0;display:flex;flex-direction:column;gap:3px}.notif-item .notif-item-title{color:var(--tn-fg);font-size:.95rem;letter-spacing:.02em;word-break:break-word}.notif-item .notif-item-time{color:var(--tn-muted);font-size:.8rem}.notif-item .notif-delete-btn{background:0 0;border:none;color:var(--tn-muted);padding:0 2px;line-height:1;cursor:pointer;flex-shrink:0;font-size:1.1rem;opacity:0;transition:color .15s,opacity .15s}.notif-item .notif-delete-btn:hover{color:var(--tn-red)}.notif-item:hover .notif-delete-btn{opacity:1}.notif-item .notif-item-msg{margin-top:8px;color:var(--tn-muted);font-size:.86rem;word-break:break-all;overflow-wrap:anywhere;line-height:1.55}.notif-item .notif-expand-btn{background:0 0;border:none;color:var(--tn-blue);font-family:"JetBrains Mono",monospace;font-size:.76rem;cursor:pointer;padding:3px 0;margin-top:3px}.notif-item .notif-expand-btn:hover{color:var(--tn-cyan)}.component.media-list .do-rescan-media-lib .spinner-border{display:none}.component.media-list .do-rescan-media-lib.inprogress .spinner-border{display:block}.component.errors-center{position:fixed;bottom:0;width:100%;max-width:500px;padding:0 20px;box-sizing:border-box;left:calc(50% - 250px);z-index:100}#file-history .history-item{padding:16px 20px}#file-history .history-item .params-table{margin-top:10px}#file-history .history-item .params-table td{padding:4px 8px;font-size:.9em}#file-history .history-item .params-table td:first-child{width:130px;color:#666}#file-history .history-item .snapshot-before{margin-top:12px}#file-history .history-item .snapshot-before small{font-size:.85em}#file-history .history-item .snapshot-before .badge{font-size:.85em;padding:5px 10px}#file-history .history-item>.d-flex .fw-semibold{font-size:1.05em}#file-history .history-item>.d-flex small{font-size:.9em}#file-history .history-item>div:last-child small{font-size:.9em}.thumbnails-row{display:flex;gap:8px}.thumbnails-row .thumb-placeholder{flex:1;height:90px;background-color:var(--tn-bg-card);border:1px solid var(--tn-border);border-radius:6px;animation:thumb-pulse 1.5s ease-in-out infinite}.thumbnails-row .thumb-img{flex:1;height:90px;object-fit:cover;border-radius:6px;border:1px solid var(--tn-border);cursor:pointer;transition:border-color .2s,box-shadow .2s,transform .15s}.thumbnails-row .thumb-img:hover{border-color:var(--tn-blue);box-shadow:0 0 14px rgba(122,162,247,.25);transform:translateY(-2px)}@keyframes thumb-pulse{0%,100%{opacity:.4}50%{opacity:.7}}#thumb-lightbox .modal-content{background-color:var(--tn-bg-dark);border-color:var(--tn-border)} /*# sourceMappingURL=main.css.map */ diff --git a/static/css/main.css.map b/static/css/main.css.map index 30443d9..35a1837 100644 --- a/static/css/main.css.map +++ b/static/css/main.css.map @@ -1 +1 @@ -{"version":3,"sources":["main.scss","components/media-list.scss","components/errors-center.scss"],"names":[],"mappings":"AAAA,EACE,gBAAA,KAKF,2BACC,MAAA,KACA,OAAA,KAAA,KACA,WAAA,OAGD,QACE,MAAA,KACA,OAAA,KACA,OAAA,IAAA,MAAA,KACA,oBAAA,YACA,cAAA,IACA,QAAA,aACA,WAAA,WACA,UAAA,SAAA,GAAA,OAAA,SAGF,oBACE,GACE,UAAA,UAGF,KACE,UAAA,gBAMJ,4BACC,WAAA,KACA,QAAA,KACA,UAAA,KACC,eAAA,IACA,IAAA,KAGF,0CACC,QAAA,KACA,UAAA,OACC,eAAA,IACA,gBAAA,WACA,IAAA,KACA,YAAA,OAGF,iDACC,YAAA,OACA,QAAA,aACC,MAAA,MAGF,WACE,YAAA,IAGF,UACE,WAAA,KACA,cAAA,EAOF,WACE,iBAAA,GAAA,CAAA,IAGF,iBACE,WAAA,iBAAA,IAAA,KAGF,sBACE,QAAA,KACA,eAAA,IACA,gBAAA,cACA,YAAA,OACA,IAAA,KAGF,4BACE,QAAA,KACA,eAAA,IACA,IAAA,IACA,YAAA,OAGF,YACE,MAAA,KACA,OAAA,KACA,aAAA,IAIA,qBACE,aAAA,IACA,SAAA,SACA,IAAA,IAGF,gBACE,QAAA,YACA,IAAA,IACA,YAAA,OC5GF,2DACC,QAAA,KAIA,sEACC,QAAA,MCRJ,yBACC,SAAA,MACA,OAAA,EACA,MAAA,KACA,UAAA,MACA,QAAA,EAAA,KACA,WAAA,WACA,KAAA,kBACA,QAAA","file":"main.css","sourcesContent":["a {\n text-decoration: none;\n}\n\n/* LOADING SPINNER */\n\n.loading-spinner-container {\n\twidth: 100%;\n\tmargin: 50px auto;\n\ttext-align: center;\n}\n\n.loader {\n width: 48px;\n height: 48px;\n border: 5px solid #777;\n border-bottom-color: transparent;\n border-radius: 50%;\n display: inline-block;\n box-sizing: border-box;\n animation: rotation 1s linear infinite;\n}\n\n@keyframes rotation {\n 0% {\n transform: rotate(0deg);\n }\n\n 100% {\n transform: rotate(360deg);\n }\n} \n\n/* END LOADING SPINNER */\n\n.transcodate-form-container {\n\tmargin-top: 20px;\n\tdisplay: flex;\n\tflex-wrap: wrap;\n flex-direction: row;\n gap: 10px;\n}\n\n.transcodate-form-container > .form-control {\n\tdisplay: flex;\n\tflex-wrap: nowrap;\n flex-direction: row;\n justify-content: flex-start;\n gap: 10px;\n align-items: center;\n}\n\n.transcodate-form-container > .form-control > strong {\n\twhite-space: nowrap;\n\tdisplay: inline-block;\n width: 100px;\n}\n\n.crf-range {\n margin-left: 5px;\n}\n\n#progress {\n margin-top: 20px;\n margin-bottom: 0;\n}\n\n.progress-circle {\n/* transform: rotate(-90deg);*/\n}\n\n.circle-bg {\n stroke-dasharray: 100, 100;\n}\n\n.circle-progress {\n transition: stroke-dasharray 0.3s ease;\n}\n\n.list-group-item.task {\n display: flex;\n flex-direction: row;\n justify-content: space-between;\n align-items: center;\n gap: 12px;\n}\n\n.list-group-item.task .file {\n display: flex;\n flex-direction: row;\n gap: 8px;\n align-items: center;\n}\n\n.sm-spinner {\n width: 18px;\n height: 18px;\n border-width: 3px;\n} \n\n.btn {\n .border-spinner {\n margin-right: 2px;\n position: relative;\n top: 1px;\n }\n\n &.inprogress {\n display: inline-flex;\n gap: 6px;\n align-items: center;\n }\n}",".component.media-list {\n\t.do-rescan-media-lib {\n\t\t.spinner-border {\n\t\t\tdisplay: none;\n\t\t}\n\n\t\t&.inprogress {\n\t\t\t.spinner-border {\n\t\t\t\tdisplay: block;\n\t\t\t}\n\t\t}\n\t}\n}",".component.errors-center {\n\tposition: fixed;\n\tbottom: 0;\n\twidth: 100%;\n\tmax-width: 500px;\n\tpadding: 0 20px;\n\tbox-sizing: border-box;\n\tleft: calc(50% - 250px);\n\tz-index: 100;\n}"]} \ No newline at end of file +{"version":3,"sources":["main.scss","components/media-list.scss","components/errors-center.scss","components/file-history.scss","components/thumbnails.scss"],"names":[],"mappings":"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","file":"main.css","sourcesContent":["// Tokyo Night palette\n:root {\n --tn-bg: #1a1b26;\n --tn-bg-dark: #16161e;\n --tn-bg-card: #1f2335;\n --tn-border: #292e42;\n --tn-fg: #c0caf5;\n --tn-fg-dim: #a9b1d6;\n --tn-muted: #565f89;\n --tn-blue: #7aa2f7;\n --tn-cyan: #7dcfff;\n --tn-purple: #bb9af7;\n --tn-green: #9ece6a;\n --tn-yellow: #e0af68;\n --tn-red: #f7768e;\n}\n\n// Bootstrap muted override\n.text-muted { color: var(--tn-fg-dim) !important; }\n\n// Force line breaks for long unbreakable strings (filenames, paths)\nh1, h2, h3, h4, h5, h6,\n.filename a,\n.filepath,\n.stream-title,\ntd,\n.file a,\n.modal-body strong {\n word-break: break-word;\n overflow-wrap: anywhere;\n}\n\n// Base\n* { box-sizing: border-box; }\n\nbody {\n font-family: 'JetBrains Mono', monospace;\n background-color: var(--tn-bg);\n color: var(--tn-fg);\n font-size: 1rem;\n}\n\na {\n text-decoration: none;\n color: var(--tn-blue);\n &:hover { color: var(--tn-cyan); }\n}\n\n// Headings\nh1, h2, h3, h4, h5, h6 {\n color: var(--tn-blue);\n letter-spacing: 0.04em;\n font-weight: 400;\n}\n\n// Cards\n.card {\n background-color: var(--tn-bg-card);\n border: 1px solid var(--tn-border);\n border-radius: 8px;\n transition: border-color 0.2s, box-shadow 0.2s;\n\n .card-header {\n background-color: var(--tn-bg-dark);\n border-bottom: 1px solid var(--tn-border);\n color: var(--tn-blue);\n letter-spacing: 0.04em;\n }\n\n .card-body { color: var(--tn-fg); }\n}\n\n// Tables\n.table {\n color: var(--tn-fg);\n border-color: var(--tn-border);\n\n thead th {\n color: var(--tn-blue);\n font-weight: 400;\n border-color: var(--tn-border);\n letter-spacing: 0.04em;\n background-color: var(--tn-bg-dark);\n }\n\n td {\n color: var(--tn-fg);\n border-color: var(--tn-border);\n padding: 6px 10px;\n }\n\n &.table-striped > tbody > tr:nth-of-type(odd) > * {\n background-color: rgba(41, 46, 66, 0.4);\n color: var(--tn-fg);\n }\n\n &.table-hover > tbody > tr:hover > * {\n background-color: rgba(122, 162, 247, 0.08);\n color: var(--tn-fg);\n }\n}\n\n// DataTables overrides\n.dataTables_wrapper {\n color: var(--tn-fg);\n\n .dataTables_filter input,\n .dataTables_length select {\n background-color: var(--tn-bg-dark);\n border: 1px solid var(--tn-border);\n color: var(--tn-fg);\n border-radius: 4px;\n padding: 2px 8px;\n font-family: 'JetBrains Mono', monospace;\n outline: none;\n &:focus { border-color: var(--tn-blue); }\n }\n\n .dataTables_info,\n .dataTables_filter label,\n .dataTables_length label { color: var(--tn-muted); }\n\n .dataTables_paginate .paginate_button {\n color: var(--tn-muted) !important;\n border-radius: 4px !important;\n &:hover { background: var(--tn-bg-card) !important; color: var(--tn-blue) !important; border-color: var(--tn-border) !important; }\n &.current { background: var(--tn-bg-card) !important; color: var(--tn-blue) !important; border-color: var(--tn-border) !important; }\n &.disabled { color: var(--tn-border) !important; }\n }\n}\n\n// Buttons\n.btn {\n font-family: 'JetBrains Mono', monospace;\n border-radius: 4px;\n letter-spacing: 0.02em;\n\n &.border-spinner { margin-right: 2px; position: relative; top: 1px; }\n &.inprogress { display: inline-flex; gap: 6px; align-items: center; }\n}\n\n.btn-primary {\n background-color: var(--tn-blue);\n border-color: var(--tn-blue);\n color: var(--tn-bg-dark);\n &:hover { background-color: var(--tn-cyan); border-color: var(--tn-cyan); color: var(--tn-bg-dark); }\n}\n\n.btn-warning {\n background-color: var(--tn-yellow);\n border-color: var(--tn-yellow);\n color: var(--tn-bg-dark);\n &:hover { background-color: #f0c070; border-color: #f0c070; color: var(--tn-bg-dark); }\n}\n\n.btn-success {\n background-color: var(--tn-green);\n border-color: var(--tn-green);\n color: var(--tn-bg-dark);\n &:hover { background-color: #acd87a; border-color: #acd87a; color: var(--tn-bg-dark); }\n}\n\n.btn-danger {\n background-color: var(--tn-red);\n border-color: var(--tn-red);\n color: var(--tn-bg-dark);\n &:hover { background-color: #f98a9e; border-color: #f98a9e; color: var(--tn-bg-dark); }\n}\n\n.btn-outline-secondary {\n border-color: var(--tn-border);\n color: var(--tn-fg-dim);\n background-color: transparent;\n &:hover { background-color: var(--tn-bg-card); border-color: var(--tn-blue); color: var(--tn-blue); box-shadow: 0 0 12px rgba(122,162,247,0.15); }\n}\n\n.btn-outline-primary {\n border-color: var(--tn-blue);\n color: var(--tn-blue);\n background-color: transparent;\n &:hover { background-color: rgba(122,162,247,0.1); color: var(--tn-blue); }\n}\n\n.btn-close {\n filter: invert(1) brightness(0.6);\n &:hover { filter: invert(1) brightness(1); }\n}\n\n// Forms\n.form-select, .form-control {\n background-color: var(--tn-bg-dark);\n border: 1px solid var(--tn-border);\n color: var(--tn-fg);\n font-family: 'JetBrains Mono', monospace;\n border-radius: 4px;\n\n &:focus {\n background-color: var(--tn-bg-dark);\n border-color: var(--tn-blue);\n color: var(--tn-fg);\n box-shadow: 0 0 0 2px rgba(122,162,247,0.15);\n }\n\n option { background-color: var(--tn-bg-card); }\n}\n\n.form-check-input {\n background-color: var(--tn-bg-dark);\n border-color: var(--tn-border);\n &:checked { background-color: var(--tn-blue); border-color: var(--tn-blue); }\n &:focus { box-shadow: 0 0 0 2px rgba(122,162,247,0.15); border-color: var(--tn-blue); }\n}\n\n.form-check-label { color: var(--tn-fg-dim); }\n\n.input-group-text {\n background-color: var(--tn-bg-dark);\n border-color: var(--tn-border);\n color: var(--tn-muted);\n font-family: 'JetBrains Mono', monospace;\n}\n\n// Alerts\n.alert {\n background-color: var(--tn-bg-card);\n border-color: var(--tn-border);\n color: var(--tn-fg);\n\n &.alert-dark {\n background-color: var(--tn-bg-dark);\n border-color: var(--tn-border);\n color: var(--tn-fg-dim);\n }\n}\n\n// Badges\n.badge {\n font-family: 'JetBrains Mono', monospace;\n font-weight: 400;\n\n &.bg-success { background-color: var(--tn-green) !important; color: var(--tn-bg-dark); }\n &.bg-danger { background-color: var(--tn-red) !important; color: var(--tn-bg-dark); }\n &.bg-warning { background-color: var(--tn-yellow) !important; color: var(--tn-bg-dark); }\n &.bg-secondary { background-color: var(--tn-muted) !important; color: var(--tn-fg); }\n &.bg-light { background-color: var(--tn-bg-dark) !important; color: var(--tn-fg) !important; border-color: var(--tn-border) !important; }\n}\n\n// Modal\n.modal-content {\n background-color: var(--tn-bg-card);\n border: 1px solid var(--tn-border);\n border-radius: 8px;\n color: var(--tn-fg);\n}\n\n.modal-header {\n background-color: var(--tn-bg-dark);\n border-bottom: 1px solid var(--tn-border);\n color: var(--tn-blue);\n}\n\n.modal-footer {\n background-color: var(--tn-bg-dark);\n border-top: 1px solid var(--tn-border);\n color: var(--tn-muted);\n}\n\n// List group\n.list-group-item {\n background-color: var(--tn-bg-card);\n border-color: var(--tn-border);\n color: var(--tn-fg);\n\n &.task {\n display: flex;\n flex-direction: row;\n justify-content: space-between;\n align-items: center;\n gap: 12px;\n\n .file {\n display: flex;\n flex-direction: row;\n gap: 8px;\n align-items: center;\n min-width: 0;\n\n a { min-width: 0; }\n }\n }\n}\n\n// Progress bar\n.progress {\n background-color: var(--tn-bg-dark);\n border: 1px solid var(--tn-border);\n\n .progress-bar {\n background-color: var(--tn-blue);\n }\n}\n\n// Spinners\n.loader {\n width: 48px;\n height: 48px;\n border: 5px solid var(--tn-border);\n border-bottom-color: var(--tn-blue);\n border-radius: 50%;\n display: inline-block;\n box-sizing: border-box;\n animation: rotation 1s linear infinite;\n}\n\n@keyframes rotation {\n 0% { transform: rotate(0deg); }\n 100% { transform: rotate(360deg); }\n}\n\n.loading-spinner-container {\n width: 100%;\n margin: 50px auto;\n text-align: center;\n}\n\n.sm-spinner { width: 18px; height: 18px; border-width: 3px; }\n\n// Nav brand\n.nav-brand {\n display: flex;\n align-items: center;\n line-height: 1;\n}\n\n.nav-icon-btn {\n background-color: var(--tn-bg-card);\n border: 1px solid var(--tn-border);\n color: var(--tn-muted);\n border-radius: 50% !important;\n width: 52px;\n height: 52px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: border-color 0.2s, box-shadow 0.2s, color 0.2s;\n &:hover {\n border-color: var(--tn-blue);\n color: var(--tn-blue);\n box-shadow: 0 0 12px rgba(122,162,247,0.25);\n }\n}\n\n// Header nav button\n.open-transcoding-tasks {\n background-color: var(--tn-bg-card);\n border: 1px solid var(--tn-border);\n color: var(--tn-muted);\n border-radius: 50% !important;\n width: 52px;\n height: 52px;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n transition: border-color 0.2s, box-shadow 0.2s, color 0.2s;\n\n &:hover {\n border-color: var(--tn-blue);\n color: var(--tn-blue);\n box-shadow: 0 0 12px rgba(122,162,247,0.25);\n }\n\n .total-tasks:not(:empty) {\n position: absolute;\n top: -6px;\n right: -6px;\n background-color: var(--tn-yellow);\n color: var(--tn-bg-dark);\n font-size: 0.75rem;\n font-weight: 700;\n min-width: 20px;\n height: 20px;\n border-radius: 10px;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 0 5px;\n line-height: 1;\n }\n}\n\n// Transcoding form\n.transcodate-form-container {\n margin-top: 20px;\n display: flex;\n flex-wrap: wrap;\n flex-direction: row;\n gap: 10px;\n\n > .form-control {\n display: flex;\n flex-wrap: nowrap;\n flex-direction: row;\n justify-content: flex-start;\n gap: 10px;\n align-items: center;\n background: transparent;\n border: none;\n padding: 0;\n\n > strong {\n white-space: nowrap;\n display: inline-block;\n width: 110px;\n color: var(--tn-muted);\n font-weight: 400;\n font-size: 0.875rem;\n letter-spacing: 0.02em;\n }\n }\n\n .arrow-to { color: var(--tn-muted); }\n\n .btns { width: 100%; }\n}\n\n.crf-range { margin-left: 5px; color: var(--tn-muted); }\n\n#progress {\n margin-top: 20px;\n margin-bottom: 0;\n font-size: 0.85rem;\n color: var(--tn-fg-dim);\n}\n\n// Modal empty state\n.empty-tasks-message { color: var(--tn-fg-dim); }\n\n// Media list file details\n.filename a {\n color: var(--tn-fg);\n font-weight: 400;\n &:hover { color: var(--tn-blue); }\n}\n\n.filepath {\n font-size: 0.78rem;\n color: var(--tn-muted);\n margin-bottom: 6px;\n}\n\n.filedetails {\n display: flex;\n flex-direction: column;\n gap: 3px;\n}\n\n.streams-section {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n}\n\n.stream-label {\n font-size: 0.7rem;\n color: var(--tn-muted);\n letter-spacing: 0.06em;\n text-transform: uppercase;\n min-width: 36px;\n padding-top: 3px;\n flex-shrink: 0;\n}\n\n.stream-rows {\n display: flex;\n flex-direction: column;\n gap: 3px;\n}\n\n.stream-row {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 4px;\n}\n\n.stream-bitrate {\n font-size: 0.75rem;\n color: var(--tn-muted);\n}\n\n.stream-title {\n font-size: 0.75rem;\n color: var(--tn-fg-dim);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 380px;\n}\n\n// Language badges\n.lang-badge {\n font-size: 0.68rem;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n min-width: 30px;\n text-align: center;\n}\n.lang-ukr { background-color: var(--tn-cyan) !important; color: var(--tn-bg-dark); }\n.lang-eng { background-color: var(--tn-blue) !important; color: var(--tn-bg-dark); }\n.lang-rus { background-color: var(--tn-red) !important; color: var(--tn-bg-dark); }\n.lang-jpn { background-color: var(--tn-purple) !important; color: var(--tn-bg-dark); }\n.lang-deu,\n.lang-fra,\n.lang-spa,\n.lang-ita,\n.lang-pol { background-color: var(--tn-green) !important; color: var(--tn-bg-dark); }\n.lang-und { background-color: var(--tn-muted) !important; color: var(--tn-fg); }\n\n// Footer stats\n.footer {\n border-top: 1px solid var(--tn-border);\n padding: 16px 0 20px;\n}\n\n.footer-stats {\n display: flex;\n align-items: center;\n gap: 20px;\n flex-wrap: wrap;\n}\n\n.footer-stat {\n display: flex;\n align-items: center;\n gap: 6px;\n}\n\n.footer-stat-icon {\n color: var(--tn-muted);\n font-size: 0.95rem;\n}\n\n.footer-stat-value {\n color: var(--tn-blue);\n font-size: 0.9rem;\n letter-spacing: 0.02em;\n}\n\n.footer-stat-label {\n color: var(--tn-muted);\n font-size: 0.78rem;\n letter-spacing: 0.03em;\n}\n\n.footer-stat-divider {\n width: 1px;\n height: 16px;\n background-color: var(--tn-border);\n}\n\n// Nav logo\n.nav-logo {\n height: 32px;\n width: auto;\n display: block;\n transition: opacity 0.2s, filter 0.2s;\n filter: brightness(0.85);\n\n .nav-brand:hover & {\n opacity: 1;\n filter: brightness(1) drop-shadow(0 0 8px rgba(122, 162, 247, 0.5));\n }\n}\n\n// Auth logo\n.auth-logo {\n max-height: 100px;\n width: auto;\n display: block;\n margin: 0 auto;\n}\n\n// Nav user info\n.nav-user-divider {\n width: 1px;\n height: 24px;\n background-color: var(--tn-border);\n margin: 0 4px;\n flex-shrink: 0;\n}\n\n.nav-username {\n color: var(--tn-muted);\n font-size: 0.78rem;\n letter-spacing: 0.04em;\n white-space: nowrap;\n line-height: 1;\n}\n\n// Auth pages\n.auth-page {\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 2rem 1rem;\n}\n\n.auth-card {\n background-color: var(--tn-bg-card);\n border: 1px solid var(--tn-border);\n border-radius: 10px;\n padding: 2.5rem 2rem;\n width: 100%;\n max-width: 380px;\n}\n\n.auth-logo-space {\n height: 100px;\n margin-bottom: 1.5rem;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.auth-title {\n color: var(--tn-blue);\n font-size: 1.15rem;\n letter-spacing: 0.06em;\n font-weight: 400;\n margin-bottom: 0.25rem;\n text-align: center;\n}\n\n.auth-subtitle {\n color: var(--tn-muted);\n font-size: 0.78rem;\n text-align: center;\n margin-bottom: 1.5rem;\n letter-spacing: 0.03em;\n}\n\n.auth-field {\n margin-bottom: 1.1rem;\n\n label {\n display: block;\n color: var(--tn-muted);\n font-size: 0.78rem;\n letter-spacing: 0.04em;\n margin-bottom: 0.3rem;\n }\n}\n\n.auth-input-wrap {\n position: relative;\n display: flex;\n align-items: center;\n}\n\n.auth-input {\n width: 100%;\n background-color: var(--tn-bg-dark);\n border: 1px solid var(--tn-border);\n border-radius: 6px;\n color: var(--tn-fg);\n font-family: 'JetBrains Mono', monospace;\n font-size: 0.9rem;\n padding: 0.55rem 2.4rem 0.55rem 0.75rem;\n outline: none;\n transition: border-color 0.2s;\n\n &:focus {\n border-color: var(--tn-blue);\n }\n\n // standalone input (no wrap)\n &:not(.auth-input-wrap .auth-input) {\n padding-right: 0.75rem;\n }\n}\n\n.auth-eye-btn {\n position: absolute;\n right: 0.55rem;\n background: none;\n border: none;\n color: var(--tn-muted);\n cursor: pointer;\n padding: 0;\n line-height: 1;\n font-size: 1rem;\n\n &:hover { color: var(--tn-fg-dim); }\n}\n\n.auth-btn {\n width: 100%;\n margin-top: 0.5rem;\n padding: 0.6rem;\n background-color: var(--tn-blue);\n color: var(--tn-bg-dark);\n border: none;\n border-radius: 6px;\n font-family: 'JetBrains Mono', monospace;\n font-size: 0.9rem;\n letter-spacing: 0.04em;\n cursor: pointer;\n transition: background-color 0.2s;\n\n &:hover { background-color: var(--tn-cyan); }\n &:disabled { opacity: 0.5; cursor: default; }\n}\n\n.auth-error {\n background-color: rgba(247, 118, 142, 0.12);\n border: 1px solid var(--tn-red);\n border-radius: 6px;\n color: var(--tn-red);\n font-size: 0.82rem;\n padding: 0.5rem 0.75rem;\n margin-bottom: 1rem;\n}\n\n.auth-success {\n background-color: rgba(158, 206, 106, 0.12);\n border: 1px solid var(--tn-green);\n border-radius: 6px;\n color: var(--tn-green);\n font-size: 0.82rem;\n padding: 0.5rem 0.75rem;\n margin-bottom: 1rem;\n}\n\n// Scanning message\n.scaning-process-container {\n font-size: 0.85rem;\n color: var(--tn-muted);\n background-color: var(--tn-bg-dark);\n border-color: var(--tn-border);\n word-break: break-all;\n overflow-wrap: anywhere;\n}\n\n// ── Notification bell badge ────────────────────────────────────────────────────\n.open-notifications {\n position: relative;\n\n .notif-badge:not(:empty) {\n position: absolute;\n top: -6px;\n right: -6px;\n background-color: var(--tn-red);\n color: var(--tn-bg-dark);\n font-size: 0.7rem;\n font-weight: 700;\n min-width: 18px;\n height: 18px;\n border-radius: 9px;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 0 4px;\n line-height: 1;\n }\n}\n\n// ── Notification toast ─────────────────────────────────────────────────────────\n.notif-toast {\n background-color: var(--tn-bg-card);\n border: 1px solid var(--tn-border);\n border-left: 3px solid var(--notif-color, var(--tn-blue));\n border-radius: 6px;\n padding: 10px 12px;\n margin-bottom: 8px;\n opacity: 0;\n transform: translateX(16px);\n transition: opacity 0.25s, transform 0.25s;\n max-width: 360px;\n font-size: 0.83rem;\n\n &.show {\n opacity: 1;\n transform: translateX(0);\n }\n\n .notif-toast-header {\n display: flex;\n align-items: center;\n gap: 7px;\n }\n\n .notif-toast-icon {\n color: var(--notif-color, var(--tn-blue));\n flex-shrink: 0;\n font-size: 0.9rem;\n }\n\n .notif-toast-title {\n color: var(--tn-fg);\n font-weight: 400;\n letter-spacing: 0.02em;\n flex-grow: 1;\n }\n\n .notif-toast-msg {\n margin-top: 6px;\n color: var(--tn-muted);\n font-size: 0.78rem;\n max-height: 80px;\n overflow: hidden;\n text-overflow: ellipsis;\n display: -webkit-box;\n -webkit-line-clamp: 3;\n -webkit-box-orient: vertical;\n word-break: break-all;\n overflow-wrap: anywhere;\n }\n}\n\n// ── Transcoding tasks offcanvas ────────────────────────────────────────────────\n.transcoding-offcanvas {\n background-color: var(--tn-bg-card);\n border-left: 1px solid var(--tn-border);\n width: 480px !important;\n font-family: 'JetBrains Mono', monospace;\n\n .offcanvas-header {\n background-color: var(--tn-bg-dark);\n border-bottom: 1px solid var(--tn-border);\n padding: 16px 20px;\n\n .offcanvas-title {\n color: var(--tn-blue);\n font-size: 0.95rem;\n letter-spacing: 0.04em;\n font-weight: 400;\n }\n }\n\n .transcoding-toolbar {\n border-bottom: 1px solid var(--tn-border);\n background-color: var(--tn-bg-dark);\n flex-shrink: 0;\n min-height: 40px;\n }\n\n .transcoding-toolbar-count {\n color: var(--tn-muted);\n font-size: 0.8rem;\n letter-spacing: 0.03em;\n\n .total-tasks { color: var(--tn-yellow); }\n }\n\n .transcoding-tasks-container {\n .list-group-item.task {\n background-color: transparent;\n border-color: rgba(41, 46, 66, 0.6);\n padding: 14px 20px;\n }\n }\n}\n\n// ── Notification offcanvas ─────────────────────────────────────────────────────\n.notifications-offcanvas {\n background-color: var(--tn-bg-card);\n border-left: 1px solid var(--tn-border);\n width: 480px !important;\n font-family: 'JetBrains Mono', monospace;\n\n .offcanvas-header {\n background-color: var(--tn-bg-dark);\n border-bottom: 1px solid var(--tn-border);\n padding: 16px 20px;\n\n .offcanvas-title {\n color: var(--tn-blue);\n font-size: 0.95rem;\n letter-spacing: 0.04em;\n font-weight: 400;\n }\n }\n\n .notif-toolbar {\n border-bottom: 1px solid var(--tn-border);\n background-color: var(--tn-bg-dark);\n flex-shrink: 0;\n padding: 8px 16px;\n\n .btn {\n font-size: 0.8rem;\n padding: 4px 12px;\n }\n }\n}\n\n.notif-list {\n padding: 8px 0;\n}\n\n.notif-item {\n padding: 14px 20px;\n border-bottom: 1px solid rgba(41, 46, 66, 0.5);\n transition: background-color 0.15s, opacity 0.25s;\n border-left: 3px solid transparent;\n\n &.is-unread {\n border-left-color: var(--notif-color, var(--tn-blue));\n background-color: rgba(122, 162, 247, 0.04);\n }\n\n &.removing { opacity: 0; }\n\n &:hover { background-color: rgba(122, 162, 247, 0.05); }\n\n .notif-item-header {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n }\n\n .notif-item-icon {\n color: var(--notif-color, var(--tn-blue));\n font-size: 1rem;\n flex-shrink: 0;\n margin-top: 2px;\n }\n\n .notif-item-meta {\n flex-grow: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 3px;\n }\n\n .notif-item-title {\n color: var(--tn-fg);\n font-size: 0.95rem;\n letter-spacing: 0.02em;\n word-break: break-word;\n }\n\n .notif-item-time {\n color: var(--tn-muted);\n font-size: 0.8rem;\n }\n\n .notif-delete-btn {\n background: none;\n border: none;\n color: var(--tn-muted);\n padding: 0 2px;\n line-height: 1;\n cursor: pointer;\n flex-shrink: 0;\n font-size: 1.1rem;\n opacity: 0;\n transition: color 0.15s, opacity 0.15s;\n\n &:hover { color: var(--tn-red); }\n }\n\n &:hover .notif-delete-btn { opacity: 1; }\n\n .notif-item-msg {\n margin-top: 8px;\n color: var(--tn-muted);\n font-size: 0.86rem;\n word-break: break-all;\n overflow-wrap: anywhere;\n line-height: 1.55;\n }\n\n .notif-expand-btn {\n background: none;\n border: none;\n color: var(--tn-blue);\n font-family: 'JetBrains Mono', monospace;\n font-size: 0.76rem;\n cursor: pointer;\n padding: 3px 0;\n margin-top: 3px;\n\n &:hover { color: var(--tn-cyan); }\n }\n}\n",".component.media-list {\n\t.do-rescan-media-lib {\n\t\t.spinner-border {\n\t\t\tdisplay: none;\n\t\t}\n\n\t\t&.inprogress {\n\t\t\t.spinner-border {\n\t\t\t\tdisplay: block;\n\t\t\t}\n\t\t}\n\t}\n}",".component.errors-center {\n\tposition: fixed;\n\tbottom: 0;\n\twidth: 100%;\n\tmax-width: 500px;\n\tpadding: 0 20px;\n\tbox-sizing: border-box;\n\tleft: calc(50% - 250px);\n\tz-index: 100;\n}","#file-history {\n .history-item {\n padding: 16px 20px;\n\n .params-table {\n margin-top: 10px;\n\n td {\n padding: 4px 8px;\n font-size: .9em;\n }\n\n td:first-child {\n width: 130px;\n color: #666;\n }\n }\n\n .snapshot-before {\n margin-top: 12px;\n\n small {\n font-size: .85em;\n }\n\n .badge {\n font-size: .85em;\n padding: 5px 10px;\n }\n }\n\n & > .d-flex {\n .fw-semibold {\n font-size: 1.05em;\n }\n\n small {\n font-size: .9em;\n }\n }\n\n & > div:last-child small {\n font-size: .9em;\n }\n }\n}\n",".thumbnails-row {\n display: flex;\n gap: 8px;\n\n .thumb-placeholder {\n flex: 1;\n height: 90px;\n background-color: var(--tn-bg-card);\n border: 1px solid var(--tn-border);\n border-radius: 6px;\n animation: thumb-pulse 1.5s ease-in-out infinite;\n }\n\n .thumb-img {\n flex: 1;\n height: 90px;\n object-fit: cover;\n border-radius: 6px;\n border: 1px solid var(--tn-border);\n cursor: pointer;\n transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;\n\n &:hover {\n border-color: var(--tn-blue);\n box-shadow: 0 0 14px rgba(122, 162, 247, 0.25);\n transform: translateY(-2px);\n }\n }\n}\n\n@keyframes thumb-pulse {\n 0%, 100% { opacity: 0.4; }\n 50% { opacity: 0.7; }\n}\n\n#thumb-lightbox .modal-content {\n background-color: var(--tn-bg-dark);\n border-color: var(--tn-border);\n}\n"]} \ No newline at end of file diff --git a/static/images/_logo mctl mini.webp b/static/images/_logo mctl mini.webp new file mode 100644 index 0000000..df57e16 --- /dev/null +++ b/static/images/_logo mctl mini.webp Binary files differ diff --git a/static/images/favicon mctl.webp b/static/images/favicon mctl.webp new file mode 100644 index 0000000..582d8e4 --- /dev/null +++ b/static/images/favicon mctl.webp Binary files differ diff --git a/static/images/logo mctl abstract full.webp b/static/images/logo mctl abstract full.webp new file mode 100644 index 0000000..73b695d --- /dev/null +++ b/static/images/logo mctl abstract full.webp Binary files differ diff --git a/static/images/logo mctl full original.webp b/static/images/logo mctl full original.webp new file mode 100644 index 0000000..ba36af1 --- /dev/null +++ b/static/images/logo mctl full original.webp Binary files differ diff --git a/static/images/logo mctl mini.webp b/static/images/logo mctl mini.webp new file mode 100644 index 0000000..98605e9 --- /dev/null +++ b/static/images/logo mctl mini.webp Binary files differ diff --git a/static/images/logo mctl.psd b/static/images/logo mctl.psd new file mode 100644 index 0000000..9770d1b --- /dev/null +++ b/static/images/logo mctl.psd Binary files differ diff --git a/static/js/components/audio-controls.js b/static/js/components/audio-controls.js new file mode 100644 index 0000000..e1efaf0 --- /dev/null +++ b/static/js/components/audio-controls.js @@ -0,0 +1,215 @@ +function audioControlsInit() { + const filePath = mediaInfo.path; + + // Transcoding lock + let transcodingLocked = false; + + function warnTranscodingLocked() { + pushInfoMsg("Audio operations are unavailable while transcoding is in progress."); + } + + $.getJSON("/file-status?path=" + encodeURIComponent(filePath), function(data) { + transcodingLocked = data.transcoding; + }); + + socket.on("progress", function(data) { + if (data.task.file === filePath) transcodingLocked = true; + }); + socket.on("copy-progress", function(data) { + if (data.file === filePath) transcodingLocked = true; + }); + socket.on("completed", function(data) { + if (data.task.file === filePath) transcodingLocked = false; + }); + socket.on("error", function(data) { + if (data.task.file === filePath) transcodingLocked = false; + }); + socket.on("canceled", function(data) { + if (data.task.file === filePath) transcodingLocked = false; + }); + + // Extract audio track + $(".btn-extract-audio").on("click", function() { + if (transcodingLocked) { warnTranscodingLocked(); return; } + const btn = $(this); + const row = btn.closest("tr"); + btn.prop("disabled", true).find(".spinner-border").show(); + btn.find("i").hide(); + + const trackMeta = { + codec: row.data("track-codec"), + title: row.data("track-title"), + language: row.data("track-language"), + bitrate: row.data("track-bitrate"), + channels: row.data("track-channels") + }; + + $.ajax({ + url: "/audio/extract", + method: "POST", + contentType: "application/json", + data: JSON.stringify({ + path: filePath, + track_index: row.data("track-index"), + track_meta: trackMeta + }), + error: function(xhr) { + btn.prop("disabled", false).find(".spinner-border").hide(); + btn.find("i").show(); + pushErrMsg("Extract failed: " + (xhr.responseJSON?.error || "unknown error")); + } + }); + }); + + socket.on("audio-extract-completed", function(data) { + if (data.file !== filePath) return; + const row = $(`tr[data-track-index="${data.track_index}"]`); + const btn = row.find(".btn-extract-audio"); + btn.prop("disabled", false).find(".spinner-border").hide(); + btn.find("i").show().removeClass("bi-box-arrow-up").addClass("bi-check-lg"); + btn.css("color", "var(--tn-green)"); + loadAudioTrackOptions(); + }); + + socket.on("audio-extract-error", function(data) { + if (data.file !== filePath) return; + const row = $(`tr[data-track-index="${data.track_index}"]`); + const btn = row.find(".btn-extract-audio"); + btn.prop("disabled", false).find(".spinner-border").hide(); + btn.find("i").show(); + pushErrMsg("Extract failed: " + data.message); + }); + + // Remove audio track + let pendingRemoveRow = null; + const removeModal = new bootstrap.Modal(document.getElementById("confirm-remove-audio")); + + $(".btn-remove-audio").on("click", function() { + if (transcodingLocked) { warnTranscodingLocked(); return; } + const row = $(this).closest("tr"); + const title = row.data("track-title") || row.data("track-language") || `track ${row.data("track-index")}`; + pendingRemoveRow = row; + $("#confirm-remove-track-name").text(title); + removeModal.show(); + }); + + $("#confirm-remove-audio-btn").on("click", function() { + if (!pendingRemoveRow) return; + const row = pendingRemoveRow; + const confirmBtn = $(this); + confirmBtn.prop("disabled", true).find(".spinner-border").show(); + + row.find(".btn-remove-audio").prop("disabled", true); + + $.ajax({ + url: "/audio/remove", + method: "POST", + contentType: "application/json", + data: JSON.stringify({ + path: filePath, + track_index: row.data("track-index") + }), + success: function() { + removeModal.hide(); + }, + error: function(xhr) { + confirmBtn.prop("disabled", false).find(".spinner-border").hide(); + row.find(".btn-remove-audio").prop("disabled", false); + removeModal.hide(); + pushErrMsg("Remove failed: " + (xhr.responseJSON?.error || "unknown error")); + } + }); + }); + + document.getElementById("confirm-remove-audio").addEventListener("hidden.bs.modal", function() { + pendingRemoveRow = null; + $("#confirm-remove-audio-btn").prop("disabled", false).find(".spinner-border").hide(); + }); + + socket.on("audio-remove-completed", function(data) { + if (data.file !== filePath) return; + window.location.reload(); + }); + + socket.on("audio-remove-error", function(data) { + if (data.file !== filePath) return; + const row = $(`tr[data-track-index="${data.track_index}"]`); + row.find(".btn-remove-audio").prop("disabled", false); + pushErrMsg("Remove failed: " + data.message); + }); + + // Add audio track panel + $("#toggle-add-audio").on("click", function() { + const body = $("#add-audio-body"); + const icon = $(this).find("i"); + if (body.is(":visible")) { + body.slideUp(); + icon.removeClass("bi-chevron-up").addClass("bi-chevron-down"); + } else { + body.slideDown(); + icon.removeClass("bi-chevron-down").addClass("bi-chevron-up"); + loadAudioTrackOptions(); + } + }); + + function loadAudioTrackOptions() { + $.getJSON("/audio/tracks", function(tracks) { + const select = $("#audio-track-select"); + const addBtn = $("#btn-add-audio"); + select.empty(); + if (!tracks.length) { + select.append(''); + addBtn.prop("disabled", true); + return; + } + select.append(''); + tracks.forEach(t => { + const label = `${t.source_name} — ${t.language || "?"} [${t.codec || "?"}] ${t.title || ""}`.trim(); + select.append(``); + }); + addBtn.prop("disabled", false); + }); + } + + $("#audio-track-select").on("change", function() { + $("#btn-add-audio").prop("disabled", !$(this).val()); + }); + + $("#btn-add-audio").on("click", function() { + if (transcodingLocked) { warnTranscodingLocked(); return; } + const trackId = $("#audio-track-select").val(); + if (!trackId) return; + + const btn = $(this); + btn.prop("disabled", true).find(".spinner-border").show(); + + $.ajax({ + url: "/audio/add", + method: "POST", + contentType: "application/json", + data: JSON.stringify({ path: filePath, audio_track_id: parseInt(trackId) }), + error: function(xhr) { + btn.prop("disabled", false).find(".spinner-border").hide(); + pushErrMsg("Add audio failed: " + (xhr.responseJSON?.error || "unknown error")); + } + }); + }); + + socket.on("audio-add-completed", function(data) { + if (data.file !== filePath) return; + window.location.reload(); + }); + + socket.on("audio-add-error", function(data) { + if (data.file !== filePath) return; + const btn = $("#btn-add-audio"); + btn.prop("disabled", false).find(".spinner-border").hide(); + pushErrMsg("Add audio failed: " + data.message); + }); +} + +$(document).ready(function() { + if (typeof mediaInfo !== "undefined") { + audioControlsInit(); + } +}); diff --git a/static/js/components/errors-center.js b/static/js/components/errors-center.js index 5455859..bb7cb0e 100644 --- a/static/js/components/errors-center.js +++ b/static/js/components/errors-center.js @@ -45,6 +45,17 @@ container.append(msgView); } +function pushInfoMsg(msg) { + const container = $(".component.errors-center"); + const html = ` + `; + const view = initErrMsgView(html, 100, 4000); + container.append(view); +} + $(document).ready(function() { }); \ No newline at end of file diff --git a/static/js/components/file-history.js b/static/js/components/file-history.js new file mode 100644 index 0000000..bb9a212 --- /dev/null +++ b/static/js/components/file-history.js @@ -0,0 +1,196 @@ +function renderHistoryItem(op, index) { + const statusClass = { + completed: "success", + failed: "danger", + canceled: "secondary", + started: "warning" + }[op.status] || "secondary"; + + const date = op.started_at + ? new Date(op.started_at + "Z").toLocaleString() + : "—"; + + let paramRows = ""; + if (op.params) { + const labels = { + codec: "Codec", resolution: "Resolution", + crf: "CRF", acceleration: "Acceleration", preset: "Preset" + }; + for (const [key, label] of Object.entries(labels)) { + if (op.params[key] != null) { + paramRows += `${label}${op.params[key]}`; + } + } + } + + let snapshotHtml = ""; + if (op.snapshot_before && op.snapshot_before.video && op.snapshot_before.video[0]) { + const v = op.snapshot_before.video[0]; + snapshotHtml = ` +
+ Before: + ${v.codec || "—"} + ${v.resolution || "—"} + ${v.bitrate || "—"} +
`; + } + + let backupHtml = ""; + if (op.backup_path) { + const deleted = op.backup_deleted === 1; + const filename = op.backup_path.split("/").at(-1); + const restoreBtn = (!deleted && index === 0) + ? `` + : ""; + const deleteBackupBtn = !deleted && index === 0 + ? `` + : ""; + backupHtml = ` +
+ Backup: + ${deleted + ? ` Deleted` + : ` ${filename}${restoreBtn}${deleteBackupBtn}` + } +
+ `; + } + + return ` +
  • +
    +
    + ${op.status} + ${op.type} +
    + ${date} +
    + ${paramRows ? `${paramRows}
    ` : ""} + ${snapshotHtml} + ${backupHtml} +
  • `; +} + +function loadFileHistory(filePath) { + const card = document.getElementById("file-history"); + const loading = card.querySelector(".history-loading"); + const empty = card.querySelector(".history-empty"); + const list = card.querySelector(".history-list"); + + loading.style.display = ""; + empty.style.display = "none"; + list.style.display = "none"; + + $.getJSON(`/file-history?path=${encodeURIComponent(filePath)}`, function(operations) { + loading.style.display = "none"; + if (!operations.length) { + empty.style.display = ""; + return; + } + list.innerHTML = operations.map(renderHistoryItem).join(""); + list.style.display = ""; + }).fail(function() { + loading.textContent = "Failed to load history"; + }); +} + +$(document).ready(function() { + if (typeof mediaInfo !== "undefined") { + loadFileHistory(mediaInfo.path); + } + + socket.on("completed", function(data) { + if (typeof mediaInfo !== "undefined" && data.task.file === mediaInfo.path) { + loadFileHistory(mediaInfo.path); + } + }); + + socket.on("restore-progress", function(data) { + const bar = $(`.restore-progress[data-operation-id="${data.operation_id}"]`); + bar.show(); + bar.find(".progress-bar").css("width", data.percent + "%"); + bar.find(".restore-percent").text(data.percent + "%"); + }); + + socket.on("restore-completed", function(data) { + if (typeof mediaInfo !== "undefined" && data.file === mediaInfo.path) { + window.location.reload(); + } + }); + + socket.on("restore-error", function(data) { + const btn = $(`.restore-btn[data-operation-id="${data.operation_id}"]`); + btn.prop("disabled", false).find(".spinner-border").hide(); + $(`.restore-progress[data-operation-id="${data.operation_id}"]`).hide(); + pushErrMsg(`Restore failed: ${data.message}`); + }); + + let pendingDeleteBackupId = null; + const deleteBackupModal = new bootstrap.Modal(document.getElementById("confirm-delete-backup")); + + $("#file-history").on("click", ".delete-backup-btn", function() { + pendingDeleteBackupId = $(this).data("operation-id"); + $("#confirm-delete-backup-filename").text($(this).data("filename")); + deleteBackupModal.show(); + }); + + $("#confirm-delete-backup-btn").on("click", function() { + if (!pendingDeleteBackupId) return; + const id = pendingDeleteBackupId; + const confirmBtn = $(this); + confirmBtn.prop("disabled", true).find(".spinner-border").show(); + + $.ajax({ + url: "/delete-backup", + method: "POST", + contentType: "application/json", + data: JSON.stringify({ operation_id: id }), + success: function() { + deleteBackupModal.hide(); + confirmBtn.prop("disabled", false).find(".spinner-border").hide(); + pendingDeleteBackupId = null; + if (typeof mediaInfo !== "undefined") loadFileHistory(mediaInfo.path); + }, + error: function(xhr) { + confirmBtn.prop("disabled", false).find(".spinner-border").hide(); + deleteBackupModal.hide(); + pendingDeleteBackupId = null; + pushErrMsg("Delete backup failed: " + (xhr.responseJSON?.error || "unknown error")); + } + }); + }); + + document.getElementById("confirm-delete-backup").addEventListener("hidden.bs.modal", function() { + pendingDeleteBackupId = null; + $("#confirm-delete-backup-btn").prop("disabled", false).find(".spinner-border").hide(); + }); + + $("#file-history").on("click", ".restore-btn", function() { + const btn = $(this); + const operationId = btn.data("operation-id"); + btn.prop("disabled", true).find(".spinner-border").show(); + + $.ajax({ + url: "/restore-original", + method: "POST", + contentType: "application/json", + data: JSON.stringify({operation_id: operationId}), + error: function(xhr) { + btn.prop("disabled", false).find(".spinner-border").hide(); + const msg = xhr.responseJSON?.error || "Unknown error"; + pushErrMsg(`Restore failed: ${msg}`); + } + }); + }); +}); diff --git a/static/js/components/global-transcoding-tasks.js b/static/js/components/global-transcoding-tasks.js index f8419b1..2e55faf 100644 --- a/static/js/components/global-transcoding-tasks.js +++ b/static/js/components/global-transcoding-tasks.js @@ -1,6 +1,81 @@ const globalTasks = {}; +const copyTasks = {}; // keyed by file path during copy phase const mediaInfoCenter = {}; +// Title management +const taskPercents = {}; // 'copy:' | 'tx:' -> {percent, phase, file} +const pageBaseTitle = (typeof mediaInfo !== "undefined" && mediaInfo.name) + ? mediaInfo.name + : "mctl"; + +// Scan animation +let _scanAnimFrame = null; +let _scanAnimInterval = null; +const _scanFrames = ["*", "^", "#", "!", "$", "&", "?"]; + +function startScanTitleAnimation() { + if (_scanAnimInterval) return; + let i = 0; + _scanAnimInterval = setInterval(() => { + document.title = `${_scanFrames[i % _scanFrames.length]} scanning... ${pageBaseTitle}`; + i++; + }, 150); +} + +function stopScanTitleAnimation() { + if (_scanAnimInterval) { + clearInterval(_scanAnimInterval); + _scanAnimInterval = null; + } + updatePageTitle(); +} + +function updatePageTitle() { + if (_scanAnimInterval) return; // scan animation takes priority + const entries = Object.values(taskPercents); + if (!entries.length) { + document.title = pageBaseTitle; + return; + } + + const isFilePage = typeof mediaInfo !== "undefined"; + if (isFilePage) { + const thisFile = entries.find(e => e.file === mediaInfo.path); + if (thisFile) { + document.title = `[${thisFile.percent}% ${thisFile.phase}] ${pageBaseTitle}`; + return; + } + } + + const avg = Math.round(entries.reduce((s, e) => s + e.percent, 0) / entries.length); + document.title = `[avg ${avg}%] ${pageBaseTitle}`; +} + +function createCopyTaskView(filePath) { + const filename = filePath.split("/").at(-1); + const href = `/single?path=${encodeURIComponent(filePath)}`; + const li = document.createElement('li'); + li.classList.add("list-group-item", "task"); + li.dataset.copyFile = filePath; + li.innerHTML = ` +
    +
    +
    + ${filename} +
    copying backup...
    +
    + 0% +
    +
    + +
    + `; + initSingleCircleProgressBar(li.querySelector(".circle-progress-bar")); + return li; +} + function createEmptyTaskView(taskId) { const li = document.createElement('li'); li.classList.add("list-group-item"); @@ -9,9 +84,9 @@ let html = `
    -
    +
    - - 99% + 0%
    @@ -27,7 +102,17 @@ } function getTotalActiveTasks() { - return Object.keys(globalTasks).length; + return Object.keys(globalTasks).length + Object.keys(copyTasks).length; +} + +function updateTasksUI() { + const total = getTotalActiveTasks(); + $(".total-tasks").text(total > 0 ? total : ""); + if (total > 0) { + $(".empty-tasks-message").hide(); + } else { + $(".empty-tasks-message").show(); + } } function initTaskView(view) { @@ -37,7 +122,7 @@ $(e.currentTarget).parent().find(".spinner-border").show(); $.getJSON(`/stop-transcoding?task_id=${taskId}`, function(resp) { - if(resp.status == "stoped" && resp.task_id == taskId) { + if(resp.status == "stopped" && resp.task_id == taskId) { if(typeof globalTasks[resp.task_id] != "undefined") { // delete globalTasks[resp.task_id]; } @@ -52,7 +137,7 @@ function updateExistsView(data, view) { const taskContainer = $(view); - const href = `/single?path=${ data.task.file }`; + const href = `/single?path=${encodeURIComponent(data.task.file)}`; const fileLink = taskContainer.find(".file a"); if(fileLink.attr("href") != href) { @@ -62,67 +147,97 @@ let time = getTimeFromTranscodingProgressMessage(data.message); const mInf = getMediaInfo(data.task.file); - + if(!mInf) { - return; + return; // media info not yet loaded — keep showing 0% } const progressPercent = calcTranscodingProgress(time, mInf.info.container.duration); + const displayPercent = (isNaN(progressPercent) || progressPercent < 0) ? 0 : Math.min(progressPercent, 99); const cpbar = taskContainer.find(".file .circle-progress-bar")[0]; - if(cpbar.dataset.value != progressPercent) { - taskContainer.find(".file .progress-percent").text(`- ${ progressPercent }%`); - cpbar.dataset.value = progressPercent; + if(cpbar.dataset.value != displayPercent) { + taskContainer.find(".file .progress-percent").text(`${displayPercent}%`); + cpbar.dataset.value = displayPercent; } } function globalTranscodingTasksInit() { const tasksContainer = $(".transcoding-tasks-container > ul"); + socket.on("copy-progress", data => { + if (typeof copyTasks[data.file] === "undefined") { + const view = createCopyTaskView(data.file); + tasksContainer.append(view); + copyTasks[data.file] = { view }; + updateTasksUI(); + } + + const view = copyTasks[data.file].view; + const cpbar = view.querySelector(".circle-progress-bar"); + cpbar.dataset.value = data.percent; + view.querySelector(".progress-percent").textContent = data.percent + "%"; + + taskPercents[`copy:${data.file}`] = { percent: data.percent, phase: 'copying', file: data.file }; + updatePageTitle(); + }); + socket.on("progress", data => { if(typeof data.task.id == "undefined") { return; } + // Remove copy-phase card if it exists for this file + if (typeof copyTasks[data.task.file] !== "undefined") { + $(copyTasks[data.task.file].view).remove(); + delete copyTasks[data.task.file]; + delete taskPercents[`copy:${data.task.file}`]; + } + if(typeof globalTasks[data.task.id] == "undefined") { - // create new view const view = createEmptyTaskView(data.task.id); tasksContainer.append(view); - globalTasks[data.task.id] = { - "data": data, - "view": view - } + globalTasks[data.task.id] = { "data": data, "view": view }; } - // update exists view updateExistsView(data, globalTasks[data.task.id].view); + updateTasksUI(); - $(".total-tasks").text(getTotalActiveTasks()); + const displayPercent = (function() { + const cpbar = globalTasks[data.task.id]?.view?.querySelector(".circle-progress-bar"); + return cpbar ? parseInt(cpbar.dataset.value) || 0 : 0; + })(); + taskPercents[`tx:${data.task.id}`] = { percent: displayPercent, phase: 'transcoding', file: data.task.file }; + updatePageTitle(); }); - socket.on("completed", data => { - if(typeof globalTasks[data.task.id] != "undefined") { - // remove task view + function cleanupTask(data) { + if (typeof copyTasks[data.task.file] !== "undefined") { + $(copyTasks[data.task.file].view).remove(); + delete copyTasks[data.task.file]; + delete taskPercents[`copy:${data.task.file}`]; + } + if (typeof globalTasks[data.task.id] !== "undefined") { $(globalTasks[data.task.id].view).remove(); delete globalTasks[data.task.id]; - - $(".total-tasks").text(getTotalActiveTasks()); + delete taskPercents[`tx:${data.task.id}`]; } - }); + updateTasksUI(); + updatePageTitle(); + } - socket.on("canceled", data => { - if(typeof globalTasks[data.task.id] != "undefined") { - // remove task view - $(globalTasks[data.task.id].view).remove(); - delete globalTasks[data.task.id]; - $(".total-tasks").text(getTotalActiveTasks()); - } - }); + socket.on("completed", data => { cleanupTask(data); }); + socket.on("canceled", data => { cleanupTask(data); }); socket.on("error", data => { + cleanupTask(data); const cmd = data.task.command.join(" "); - pushErrMsg(`${ data.message }
    ${ cmd }`); + const log = data.ffmpeg_log ? `
    ${data.ffmpeg_log}
    ` : ""; + pushErrMsg(`${data.message}
    ${cmd}${log}`); }); + + socket.on("medialib-scaning-process", () => startScanTitleAnimation()); + socket.on("medialib-scaning-complete", () => stopScanTitleAnimation()); } function getMediaInfo(path) { @@ -143,5 +258,10 @@ mediaInfoCenter[mediaInfo.path] = mediaInfo; } + document.title = pageBaseTitle; globalTranscodingTasksInit(); + + $.getJSON("/scan-status", function(data) { + if (data.scanning) startScanTitleAnimation(); + }); }); \ No newline at end of file diff --git a/static/js/components/media-list.js b/static/js/components/media-list.js index 6146fb3..5d90215 100644 --- a/static/js/components/media-list.js +++ b/static/js/components/media-list.js @@ -63,19 +63,43 @@ if (data.length > 0) { data.forEach((file, index) => { - const videoInfo = file.info.video - ? file.info.video.map((v, i) => !i ? `
  • Resolution: ${v.resolution}, Codec: ${v.codec}, Bitrate: ${v.bitrate}
  • ` : `
  • Resolution: ${v.resolution}, Codec: ${v.codec}
  • `).join("") - : "
  • No video streams
  • "; + const videoRows = (file.info.video || []) + .filter(v => v.codec !== 'mjpeg') + .map(v => { + const bitrate = v.bitrate && v.bitrate !== 'Unknown' + ? `${v.bitrate}` : ''; + return `
    + ${v.resolution || '—'} + ${v.codec || '—'} + ${bitrate} +
    `; + }).join(''); - const audioInfo = file.info.audio - ? file.info.audio.map(a => `
  • ${a.title}, Channels: ${a.layout}, Codec: ${a.codec}, Bitrate: ${a.bitrate}, Language: ${a.language}
  • `).join("") - : "
  • No audio streams
  • "; + const audioRows = (file.info.audio || []).map(a => { + const lang = (a.language || 'und').toLowerCase(); + const channels = a.layout || a.channels || '—'; + const bitrate = a.bitrate && a.bitrate !== 'Unknown' + ? `${a.bitrate}` : ''; + const title = a.title + ? `${a.title}` : ''; + return `
    + ${lang} + ${a.codec || '—'} + ${channels} + ${bitrate} + ${title} +
    `; + }).join(''); const details = ` - Video: -
      ${videoInfo}
    - Audio: -
      ${audioInfo}
    +
    + video +
    ${videoRows || ''}
    +
    +
    + audio +
    ${audioRows || ''}
    +
    `; const pathEncoded = encodeURIComponent(file.path); @@ -84,7 +108,7 @@ ${index + 1} - +
    ${file.path}
    ${details}
    diff --git a/static/js/components/notifications.js b/static/js/components/notifications.js new file mode 100644 index 0000000..c64f9a3 --- /dev/null +++ b/static/js/components/notifications.js @@ -0,0 +1,262 @@ +// Notification center + toast system + +let _notifUnreadCount = 0; +const _notifChannel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('mctl_notif_badge') : null; + +if (_notifChannel) { + _notifChannel.onmessage = (e) => { + if (e.data.type === 'badge') _setBadge(e.data.count, false); + }; +} + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +function _timeAgo(isoStr) { + const diff = Math.floor((Date.now() - new Date(isoStr + 'Z').getTime()) / 1000); + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +function _notifIcon(type) { + return { + success: 'bi-check-circle', + error: 'bi-exclamation-triangle', + warning: 'bi-exclamation-circle', + info: 'bi-info-circle' + }[type] || 'bi-bell'; +} + +function _notifColor(type) { + return { + success: 'var(--tn-green)', + error: 'var(--tn-red)', + warning: 'var(--tn-yellow)', + info: 'var(--tn-blue)' + }[type] || 'var(--tn-blue)'; +} + +// ─── Badge ──────────────────────────────────────────────────────────────────── + +function _setBadge(count, broadcast = true) { + _notifUnreadCount = Math.max(0, count); + const badge = document.querySelector('.open-notifications .notif-badge'); + if (badge) badge.textContent = _notifUnreadCount > 0 ? (_notifUnreadCount > 99 ? '99+' : _notifUnreadCount) : ''; + if (broadcast && _notifChannel) _notifChannel.postMessage({ type: 'badge', count: _notifUnreadCount }); +} + +function _adjustBadge(delta) { + _setBadge(_notifUnreadCount + delta); +} + +// ─── Toast ──────────────────────────────────────────────────────────────────── + +function pushNotificationToast(notif) { + const icon = _notifIcon(notif.type); + const color = _notifColor(notif.type); + const msgHtml = notif.message + ? `
    ${notif.message.replace(/` + : ''; + + const el = document.createElement('div'); + el.className = 'notif-toast shadow'; + el.style.setProperty('--notif-color', color); + el.innerHTML = ` +
    + + ${notif.title.replace(/ + +
    + ${msgHtml} + `; + + let hovered = false; + let markedRead = false; + let autoTimer = null; + + function markRead() { + if (markedRead || !notif.id) return; + markedRead = true; + $.post(`/notifications/${notif.id}/read`); + _adjustBadge(-1); + } + + function hide() { + el.classList.remove('show'); + setTimeout(() => el.remove(), 300); + } + + function startTimer() { + autoTimer = setTimeout(() => { if (!hovered) hide(); }, 5000); + } + + el.addEventListener('mouseenter', () => { + hovered = true; + clearTimeout(autoTimer); + markRead(); + }); + el.addEventListener('mouseleave', () => { + hovered = false; + startTimer(); + }); + el.querySelector('.btn-close').addEventListener('click', () => { + markRead(); + hide(); + }); + + document.querySelector('.component.errors-center').appendChild(el); + setTimeout(() => el.classList.add('show'), 50); + startTimer(); +} + +// ─── Notification center panel ──────────────────────────────────────────────── + +function _buildNotifItem(notif) { + const icon = _notifIcon(notif.type); + const color = _notifColor(notif.type); + const time = _timeAgo(notif.created_at); + const isRead = notif.is_read; + const MSG_THRESHOLD = 120; + const longMsg = notif.message && notif.message.length > MSG_THRESHOLD; + + let msgHtml = ''; + if (notif.message) { + const escaped = notif.message.replace(/'); + const preview = longMsg + ? notif.message.slice(0, MSG_THRESHOLD).replace(/ + ${preview} +
    + ${longMsg ? `` : ''} + `; + } + + const el = document.createElement('div'); + el.className = `notif-item ${isRead ? 'is-read' : 'is-unread'}`; + el.dataset.id = notif.id; + el.style.setProperty('--notif-color', color); + el.innerHTML = ` +
    + +
    + ${notif.title.replace(/ + ${time} +
    + +
    + ${msgHtml} + `; + + if (longMsg) { + el.querySelector('.notif-expand-btn').addEventListener('click', function() { + const msgEl = el.querySelector('.notif-item-msg'); + const expanded = !msgEl.classList.contains('collapsed'); + if (expanded) { + msgEl.classList.add('collapsed'); + msgEl.innerHTML = msgEl.dataset.preview; + this.textContent = 'show more'; + } else { + msgEl.classList.remove('collapsed'); + msgEl.innerHTML = msgEl.dataset.full; + this.textContent = 'show less'; + } + }); + } + + el.querySelector('.notif-delete-btn').addEventListener('click', () => { + $.ajax({ url: `/notifications/${notif.id}`, method: 'DELETE' }); + el.classList.add('removing'); + setTimeout(() => el.remove(), 250); + _checkEmpty(); + }); + + return el; +} + +function _checkEmpty() { + setTimeout(() => { + const list = document.getElementById('notif-list'); + const empty = document.getElementById('notif-empty'); + if (list && empty) { + const hasItems = list.querySelectorAll('.notif-item').length > 0; + empty.classList.toggle('d-none', hasItems); + } + }, 300); +} + +function loadNotifications() { + const list = document.getElementById('notif-list'); + const empty = document.getElementById('notif-empty'); + const loading = document.getElementById('notif-loading'); + if (!list) return; + + loading.classList.remove('d-none'); + list.innerHTML = ''; + empty.classList.add('d-none'); + + $.getJSON('/notifications', function(data) { + loading.classList.add('d-none'); + if (!data.items || data.items.length === 0) { + empty.classList.remove('d-none'); + return; + } + data.items.forEach(n => list.appendChild(_buildNotifItem(n))); + }); +} + +// ─── Init ───────────────────────────────────────────────────────────────────── + +$(document).ready(function() { + // Fetch initial unread count + $.getJSON('/notifications/unread-count', function(data) { + _setBadge(data.count || 0); + }); + + // Open panel → load + mark all read + const offcanvasEl = document.getElementById('notifications-center'); + if (offcanvasEl) { + offcanvasEl.addEventListener('show.bs.offcanvas', () => { + loadNotifications(); + }); + offcanvasEl.addEventListener('shown.bs.offcanvas', () => { + $.post('/notifications/read-all', function() { + _setBadge(0); + offcanvasEl.querySelectorAll('.notif-item.is-unread').forEach(el => { + el.classList.remove('is-unread'); + el.classList.add('is-read'); + }); + }); + }); + } + + // Mark all read button + $('#notif-mark-all-read').on('click', function() { + $.post('/notifications/read-all', function() { + _setBadge(0); + document.querySelectorAll('#notif-list .notif-item').forEach(el => { + el.classList.remove('is-unread'); + el.classList.add('is-read'); + }); + }); + }); + + // Clear all button + $('#notif-clear-all').on('click', function() { + $.ajax({ url: '/notifications', method: 'DELETE', success: function() { + document.getElementById('notif-list').innerHTML = ''; + _setBadge(0); + _checkEmpty(); + }}); + }); + + // SocketIO: incoming notification + if (typeof socket !== 'undefined') { + socket.on('notification', function(notif) { + _adjustBadge(1); + pushNotificationToast(notif); + }); + } +}); diff --git a/static/js/components/single-transcoding.js b/static/js/components/single-transcoding.js index f4390a1..10d819e 100644 --- a/static/js/components/single-transcoding.js +++ b/static/js/components/single-transcoding.js @@ -32,6 +32,23 @@ !progressContainer.is(":visible") && progressContainer.slideDown(); progressContainer.html(msg); }; + // Listen for copy progress + socket.on("copy-progress", (data) => { + if(progressContainer.data("file-path") != data.file) return; + + $(".transcodate-form-container").slideUp(); + $(".show-transcodate-form").hide(); + $(".transcodate-form-container .run-transcodate").prop("disabled", false).find(".spinner-border").hide(); + $(".transcodate-form-container .run-transcodate").hide(); + + const progressBar = $(".progress-bar-container"); + progressBar.find(".progress-phase-label").text("copying backup..."); + progressBar.find(".progress-phase-pct").text(data.percent + "%"); + progressBar.find(".progress-bar").removeClass("bg-success").addClass("bg-warning"); + progressBar.find(".progress-bar-line")[0].setProgressValue(data.percent); + if(!progressBar.is(":visible")) progressBar.show(); + }); + // Listen for progress updates socket.on("progress", (data) => { if(progressContainer.data("file-path") != data.task.file) { @@ -43,74 +60,85 @@ $(".show-transcodate-form").hide(); $(".stop-transcoding").show(); $(".stop-transcoding").attr("data-task_id", data.task.id); + $(".transcodate-form-container .run-transcodate").prop("disabled", false).find(".spinner-border").hide(); $(".transcodate-form-container .run-transcodate").hide(); + const progressBar = $(".progress-bar-container"); + progressBar.find(".progress-phase-label").text("transcoding..."); + progressBar.find(".progress-phase-pct").text(""); + progressBar.find(".progress-bar").removeClass("bg-warning").addClass("bg-success"); + let time = getTimeFromTranscodingProgressMessage(data.message); const mInf = getMediaInfo(data.task.file); - - if(!mInf) { - return; - } - const progressPercentage = calcTranscodingProgress(time, mInf.info.container.duration); + if(!mInf) return; - const progressBar = $(".progress-bar-container"); + const progressPercentage = calcTranscodingProgress(time, mInf.info.container.duration); progressBar.find(".progress-bar-line")[0].setProgressValue(progressPercentage); - if(!progressBar.is(":visible")) { - progressBar.show(); - } + if(!progressBar.is(":visible")) progressBar.show(); }); + function resetProgressBar() { + const progressBar = $(".progress-bar-container"); + progressBar.find(".progress-bar-line")[0].setProgressValue(0); + progressBar.find(".progress-bar").removeClass("bg-warning").addClass("bg-success"); + progressBar.find(".progress-phase-label").text(""); + progressBar.find(".progress-phase-pct").text(""); + progressBar.hide(); + } + // Listen for completion socket.on("completed", (data) => { - if(progressContainer.data("file-path") != data.task.file) { - return; - } + if(progressContainer.data("file-path") != data.task.file) return; - progressContainer.pushNewMessage("Completed!"); + progressContainer.pushNewMessage( + `Completed! ` + ); $(".show-transcodate-form").show(); + $(".stop-transcoding").prop("disabled", false).find(".spinner-border").hide(); $(".stop-transcoding").hide(); $(".transcodate-form-container .run-transcodate").show(); - $(".progress-bar-container .progress-bar-line")[0].setProgressValue(0); - $(".progress-bar-container").hide(); + resetProgressBar(); }); // Listen for errors socket.on("error", (data) => { - if(progressContainer.data("file-path") != data.task.file) { - return; - } + if(progressContainer.data("file-path") != data.task.file) return; - // TODO: push to notification bar progressContainer.pushNewMessage(data.message); $(".show-transcodate-form").show(); + $(".stop-transcoding").prop("disabled", false).find(".spinner-border").hide(); $(".stop-transcoding").hide(); $(".transcodate-form-container .run-transcodate").show(); - $(".progress-bar-container .progress-bar-line")[0].setProgressValue(0); - $(".progress-bar-container").hide(); + resetProgressBar(); }); socket.on("canceled", (data) => { - if(progressContainer.data("file-path") != data.task.file) { - return; - } + if(progressContainer.data("file-path") != data.task.file) return; progressContainer.pushNewMessage(data.message); + $(".stop-transcoding").prop("disabled", false).find(".spinner-border").hide(); $(".stop-transcoding").hide(); $(".show-transcodate-form").show(); $(".transcodate-form-container .run-transcodate").show(); - $(".progress-bar-container .progress-bar-line")[0].setProgressValue(0); - $(".progress-bar-container").hide(); + resetProgressBar(); }); - $(".transcodate-form-container .run-transcodate").on("click", async () => { + async function submitTranscoding() { const formContiner = $(".transcodate-form-container"); const fields = formContiner.find("[name]"); - $(".transcodate-form-container .run-transcodate").hide(); + const runBtn = $(".transcodate-form-container .run-transcodate"); + runBtn.prop("disabled", true).find(".spinner-border").show(); const data = {}; for(let field of fields) { field = $(field); - data[field.attr("name")] = field.val(); + if(field.attr("type") === "checkbox") { + data[field.attr("name")] = field.is(":checked"); + } else { + data[field.attr("name")] = field.val(); + } } data["crf"] = "" + Math.abs(data["crf"]); @@ -125,10 +153,24 @@ document.getElementById("progress").innerText = "Processing started..."; } else { const error = await response.json(); - $(".transcodate-form-container .run-transcodate").show(); + runBtn.prop("disabled", false).find(".spinner-border").hide(); pushErrMsg(`Error: ${error.error}`); } + } + const deleteOriginalModal = new bootstrap.Modal(document.getElementById("confirm-delete-original")); + + $(".transcodate-form-container .run-transcodate").on("click", function() { + if ($("[name='delete_original']").is(":checked")) { + deleteOriginalModal.show(); + } else { + submitTranscoding(); + } + }); + + $("#confirm-run-transcode-btn").on("click", function() { + deleteOriginalModal.hide(); + submitTranscoding(); }); $("#codec").on("change", function(){ @@ -182,16 +224,18 @@ }); $(".stop-transcoding").on("click", function(){ + $(this).prop("disabled", true).find(".spinner-border").show(); stopTranscoding($(this).data("task_id")); }); } function stopTranscoding(taskId) { if(taskId && taskId != "undefined") { + $(".stop-transcoding").prop("disabled", false).find(".spinner-border").hide(); $(".stop-transcoding").hide(); $.getJSON(`/stop-transcoding?task_id=${taskId}`, function(resp) { - if(resp.status == "stoped" && resp.task_id == taskId) { + if(resp.status == "stopped" && resp.task_id == taskId) { processContainer.slideUp(); setTimeout(() => processContainer.html(""), 200); } diff --git a/static/js/components/thumbnails.js b/static/js/components/thumbnails.js new file mode 100644 index 0000000..53e61de --- /dev/null +++ b/static/js/components/thumbnails.js @@ -0,0 +1,43 @@ +function initThumbnails() { + const row = document.querySelector(".thumbnails-row"); + if (!row) return; + + const fileId = row.dataset.fileId; + if (!fileId || fileId === "None") { + row.remove(); + return; + } + + const lightbox = new bootstrap.Modal(document.getElementById("thumb-lightbox")); + const lightboxImg = document.querySelector(".lightbox-img"); + + $.getJSON(`/thumbnails/${fileId}`, function(urls) { + row.innerHTML = ""; + if (!urls.length) { + row.remove(); + return; + } + urls.forEach(url => { + const img = document.createElement("img"); + img.src = url; + img.className = "thumb-img"; + img.addEventListener("click", () => { + lightboxImg.src = url; + lightbox.show(); + }); + row.appendChild(img); + }); + }).fail(function() { + row.remove(); + }); +} + +$(document).ready(function() { + initThumbnails(); + + socket.on("completed", function(data) { + if (typeof mediaInfo !== "undefined" && data.task.file === mediaInfo.path) { + // thumbnails will refresh on page reload (triggered by single-transcoding.js) + } + }); +}); diff --git a/templates/audio-tracks.html b/templates/audio-tracks.html new file mode 100644 index 0000000..8a1e7c8 --- /dev/null +++ b/templates/audio-tracks.html @@ -0,0 +1,139 @@ +{% include "partials/head.html" %} + +
    +

    // audio tracks

    + +
    +
    +

    // extracted tracks

    +
    +
    + + + + + + + + + + + + + + + + +
    +
    +
    + + + + + + +{% include "partials/footer.html" %} diff --git a/templates/components/file-history.html b/templates/components/file-history.html new file mode 100644 index 0000000..2475129 --- /dev/null +++ b/templates/components/file-history.html @@ -0,0 +1,12 @@ +
    +
    +

    // operation history

    +
    +
    +
    Loading...
    + + +
    +
    + + diff --git a/templates/components/global-transcoding-tasks.html b/templates/components/global-transcoding-tasks.html index f79e5ec..4a3f118 100644 --- a/templates/components/global-transcoding-tasks.html +++ b/templates/components/global-transcoding-tasks.html @@ -1,21 +1,17 @@ -