fix the timeout waiting from web client

This commit is contained in:
2026-01-02 18:35:25 +01:00
parent 92f9b110ce
commit 492d336ee3
2 changed files with 433 additions and 561 deletions

283
app.py
View File

@@ -3,13 +3,15 @@
Flask server for YouTube Concert Splitter Flask server for YouTube Concert Splitter
Downloads YouTube videos and splits them into tracks based on a setlist. Downloads YouTube videos and splits them into tracks based on a setlist.
""" """
from flask import Flask, request, render_template, jsonify from flask import Flask, request, render_template, jsonify, Response
import os import os
import re import re
import subprocess import subprocess
import yt_dlp import yt_dlp
from pydub import AudioSegment from pydub import AudioSegment
from mutagen.easyid3 import EasyID3 from mutagen.easyid3 import EasyID3
import json
import sys
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', 'your_secret_key_here_change_this') app.secret_key = os.environ.get('SECRET_KEY', 'your_secret_key_here_change_this')
@@ -50,10 +52,10 @@ def download_youtube_audio(url: str, output_folder: str):
cookies_file = 'cookies.txt' cookies_file = 'cookies.txt'
has_cookies = os.path.isfile(cookies_file) has_cookies = os.path.isfile(cookies_file)
if has_cookies: if has_cookies:
print(f"✓ Using cookies from {cookies_file}") print(f"✓ Using cookies from {cookies_file}", flush=True)
else: else:
print("⚠️ No cookies.txt found - download may fail for some videos") print("⚠️ No cookies.txt found - download may fail for some videos", flush=True)
print(" See COOKIES_INSTRUCTIONS.txt for how to add cookies") print(" See COOKIES_INSTRUCTIONS.txt for how to add cookies", flush=True)
# Enhanced options to bypass YouTube restrictions # Enhanced options to bypass YouTube restrictions
ydl_opts = { ydl_opts = {
@@ -150,7 +152,7 @@ def split_audio(mp3_path: str, entries: list, album: str, artist: str, output_di
Skip tracks that already exist. Skip tracks that already exist.
Returns list of track info with status (created or skipped). Returns list of track info with status (created or skipped).
""" """
print(f"Loading audio file: {mp3_path}") print(f"Loading audio file: {mp3_path}", flush=True)
audio = AudioSegment.from_file(mp3_path) audio = AudioSegment.from_file(mp3_path)
total_ms = len(audio) total_ms = len(audio)
@@ -168,7 +170,7 @@ def split_audio(mp3_path: str, entries: list, album: str, artist: str, output_di
# Check if file already exists # Check if file already exists
if os.path.isfile(filepath): if os.path.isfile(filepath):
print(f"Skipping track {idx}/{len(entries)}: {filename} (already exists)") print(f"Skipping track {idx}/{len(entries)}: {filename} (already exists)", flush=True)
track_results.append({ track_results.append({
'filename': filename, 'filename': filename,
'status': 'skipped' 'status': 'skipped'
@@ -177,7 +179,7 @@ def split_audio(mp3_path: str, entries: list, album: str, artist: str, output_di
continue continue
# Extract and export segment # Extract and export segment
print(f"Creating track {idx}/{len(entries)}: {filename}") print(f"Creating track {idx}/{len(entries)}: {filename}", flush=True)
segment = audio[start_ms:end_ms] segment = audio[start_ms:end_ms]
# Export with 320kbps # Export with 320kbps
@@ -192,7 +194,7 @@ def split_audio(mp3_path: str, entries: list, album: str, artist: str, output_di
tags['tracknumber'] = str(idx) tags['tracknumber'] = str(idx)
tags.save() tags.save()
except Exception as e: except Exception as e:
print(f"Warning: Could not add ID3 tags to {filename}: {e}") print(f"Warning: Could not add ID3 tags to {filename}: {e}", flush=True)
track_results.append({ track_results.append({
'filename': filename, 'filename': filename,
@@ -207,11 +209,11 @@ def set_permissions(directory: str):
"""Set directory permissions to 775 recursively.""" """Set directory permissions to 775 recursively."""
try: try:
subprocess.run(['chmod', '-R', '775', directory], check=True) subprocess.run(['chmod', '-R', '775', directory], check=True)
print(f"Set permissions 775 on {directory}") print(f"Set permissions 775 on {directory}", flush=True)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
print(f"Warning: Could not set permissions: {e}") print(f"Warning: Could not set permissions: {e}", flush=True)
except FileNotFoundError: except FileNotFoundError:
print("Warning: chmod command not found (might be on Windows)") print("Warning: chmod command not found (might be on Windows)", flush=True)
@app.route('/') @app.route('/')
@@ -222,136 +224,171 @@ def index():
@app.route('/split', methods=['POST']) @app.route('/split', methods=['POST'])
def split_concert(): def split_concert():
"""Handle the split request.""" """Handle the split request."""
try: # Force flush stdout immediately
# Get form data sys.stdout.flush()
url = request.form.get('youtube_url', '').strip()
artist = request.form.get('artist', '').strip()
album = request.form.get('album', '').strip()
setlist_text = request.form.get('setlist', '').strip()
# Validate required inputs (setlist is OPTIONAL) try:
if not url: # Get form data
return jsonify({'error': 'No YouTube URL provided'}), 400 url = request.form.get('youtube_url', '').strip()
artist = request.form.get('artist', '').strip()
album = request.form.get('album', '').strip()
setlist_text = request.form.get('setlist', '').strip()
if not artist: print(f"=== Processing Request ===", flush=True)
return jsonify({'error': 'No artist name provided'}), 400 print(f"URL: {url}", flush=True)
print(f"Artist: {artist}", flush=True)
print(f"Album: {album}", flush=True)
if not album: # Validate required inputs
return jsonify({'error': 'No album name provided'}), 400 if not url:
return jsonify({'error': 'No YouTube URL provided'}), 400
# Sanitize album for directory name if not artist:
album_sanitized = sanitize(album) return jsonify({'error': 'No artist name provided'}), 400
# Create output directory directly in DOWNLOAD_FOLDER if not album:
output_dir = os.path.join(DOWNLOAD_FOLDER, album_sanitized) return jsonify({'error': 'No album name provided'}), 400
# Create directory only if it doesn't exist # Sanitize album for directory name
if not os.path.exists(output_dir): album_sanitized = sanitize(album)
os.makedirs(output_dir, exist_ok=True)
print(f"✓ Created album directory: {output_dir}")
else:
print(f"✓ Album directory already exists: {output_dir}")
# Download audio # Create output directory
print(f"Downloading audio from: {url}") output_dir = os.path.join(DOWNLOAD_FOLDER, album_sanitized)
mp3_path, info = download_youtube_audio(url, DOWNLOAD_FOLDER)
# DECISION POINT: Empty setlist = Single song mode if not os.path.exists(output_dir):
if not setlist_text: os.makedirs(output_dir, exist_ok=True)
print("📀 Single song mode: No setlist provided") print(f"✓ Created album directory: {output_dir}", flush=True)
else:
print(f"✓ Album directory already exists: {output_dir}", flush=True)
# Use video title as track name # Download audio
track_title = info.get('title', 'Unknown Track') print(f"Downloading audio from: {url}", flush=True)
filename = f"01 - {sanitize(track_title)}.mp3" sys.stdout.flush()
filepath = os.path.join(output_dir, filename)
# Check if file already exists mp3_path, info = download_youtube_audio(url, DOWNLOAD_FOLDER)
if os.path.isfile(filepath):
print(f"⊘ Track already exists: {filename}")
os.remove(mp3_path) # Clean up downloaded file
track_results = [{ print(f"✓ Download complete: {mp3_path}", flush=True)
'filename': filename, sys.stdout.flush()
'status': 'skipped'
}]
created_count = 0
skipped_count = 1
else:
# Move and rename the file (no splitting needed)
print(f"✓ Creating single track: {filename}")
# Load audio to re-export with proper tags # DECISION POINT: Empty setlist = Single song mode
audio = AudioSegment.from_file(mp3_path) if not setlist_text:
audio.export(filepath, format='mp3', bitrate='320k') print("📀 Single song mode: No setlist provided", flush=True)
# Add ID3 tags # Use video title as track name
try: track_title = info.get('title', 'Unknown Track')
tags = EasyID3(filepath) filename = f"01 - {sanitize(track_title)}.mp3"
tags['title'] = track_title filepath = os.path.join(output_dir, filename)
tags['album'] = album
tags['artist'] = artist
tags['tracknumber'] = '1'
tags.save()
print(f"✓ Added ID3 tags")
except Exception as e:
print(f"⚠️ Warning: Could not add ID3 tags: {e}")
# Clean up original # Check if file already exists
os.remove(mp3_path) if os.path.isfile(filepath):
print(f"⊘ Track already exists: {filename}", flush=True)
os.remove(mp3_path)
track_results = [{ track_results = [{
'filename': filename, 'filename': filename,
'status': 'created' 'status': 'skipped'
}] }]
created_count = 1 created_count = 0
skipped_count = 0 skipped_count = 1
else:
print(f"✓ Creating single track: {filename}", flush=True)
else: # Load audio to re-export with proper tags
# SPLIT MODE: Parse setlist and split audio audio = AudioSegment.from_file(mp3_path)
print(f"✂️ Split mode: Processing setlist with {len(setlist_text.splitlines())} lines") audio.export(filepath, format='mp3', bitrate='320k')
# Parse setlist for splitting # Add ID3 tags
try: try:
entries = parse_setlist(setlist_text) tags = EasyID3(filepath)
except ValueError as e: tags['title'] = track_title
os.remove(mp3_path) # Clean up downloaded file tags['album'] = album
return jsonify({'error': f'Setlist parsing error: {str(e)}'}), 400 tags['artist'] = artist
tags['tracknumber'] = '1'
tags.save()
print(f"✓ Added ID3 tags", flush=True)
except Exception as e:
print(f"⚠️ Warning: Could not add ID3 tags: {e}", flush=True)
if not entries: # Clean up original
os.remove(mp3_path) # Clean up downloaded file os.remove(mp3_path)
return jsonify({'error': 'No valid tracks found in setlist'}), 400
# Split audio into tracks (skipping existing ones) track_results = [{
print(f"Splitting into {len(entries)} tracks...") 'filename': filename,
track_results, created_count, skipped_count = split_audio( 'status': 'created'
mp3_path, entries, album, artist, output_dir }]
) created_count = 1
skipped_count = 0
# Clean up original MP3 else:
print(f"Removing original file: {mp3_path}") # SPLIT MODE
os.remove(mp3_path) print(f"✂️ Split mode: Processing setlist with {len(setlist_text.splitlines())} lines", flush=True)
# Set permissions # Parse setlist
set_permissions(output_dir) try:
entries = parse_setlist(setlist_text)
except ValueError as e:
os.remove(mp3_path)
return jsonify({'error': f'Setlist parsing error: {str(e)}'}), 400
# Return success response with detailed track information if not entries:
return jsonify({ os.remove(mp3_path)
'success': True, return jsonify({'error': 'No valid tracks found in setlist'}), 400
'album': album,
'artist': artist, # Split audio
'total_tracks': len(track_results), print(f"Splitting into {len(entries)} tracks...", flush=True)
'created_count': created_count, sys.stdout.flush()
'skipped_count': skipped_count,
'tracks': track_results, track_results, created_count, skipped_count = split_audio(
'output_dir': output_dir mp3_path, entries, album, artist, output_dir
}) )
# Clean up original
print(f"Removing original file: {mp3_path}", flush=True)
os.remove(mp3_path)
# Set permissions
set_permissions(output_dir)
print(f"✓ Processing complete!", flush=True)
print(f" Created: {created_count}, Skipped: {skipped_count}", flush=True)
sys.stdout.flush()
# Build response
response_data = {
'success': True,
'album': album,
'artist': artist,
'total_tracks': len(track_results),
'created_count': created_count,
'skipped_count': skipped_count,
'tracks': track_results,
'output_dir': output_dir
}
print(f"Sending response: {json.dumps(response_data, indent=2)}", flush=True)
# Return JSON response with explicit content type
return Response(
json.dumps(response_data),
status=200,
mimetype='application/json'
)
except Exception as e:
print(f"❌ Error: {str(e)}", flush=True)
import traceback
traceback.print_exc()
sys.stdout.flush()
# Return error as JSON
error_response = {'error': str(e)}
return Response(
json.dumps(error_response),
status=500,
mimetype='application/json'
)
except Exception as e:
print(f"❌ Error: {str(e)}")
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500
if __name__ == '__main__': if __name__ == '__main__':
# Get configuration from environment variables # Get configuration from environment variables
@@ -359,10 +396,8 @@ if __name__ == '__main__':
port = int(os.environ.get('FLASK_PORT', 5000)) port = int(os.environ.get('FLASK_PORT', 5000))
debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true' debug = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
print(f"Starting YouTube Concert Splitter on {host}:{port}") print(f"Starting YouTube Concert Splitter on {host}:{port}", flush=True)
print(f"Music directory: {DOWNLOAD_FOLDER}") print(f"Music directory: {DOWNLOAD_FOLDER}", flush=True)
# Run on all interfaces, configurable port
# app.run(host=host, port=port, debug=debug)
app.run(host="0.0.0.0", port=port, debug=debug)
# Run with threaded=True for better handling of long requests
app.run(host="0.0.0.0", port=port, debug=debug, threaded=True)

View File

@@ -1,166 +1,124 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YouTube Concert Splitter</title> <title>YouTube Concert Splitter</title>
<style> <style>
body { * {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0; margin: 0;
padding: 20px; padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 20px;
} }
.container { .container {
background: #fff; background: white;
padding: 40px; border-radius: 20px;
border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,0.3);
box-shadow: 0 8px 20px rgba(0,0,0,0.2); max-width: 600px;
width: 100%; width: 100%;
max-width: 700px; padding: 40px;
} }
h1 { h1 {
margin-bottom: 10px;
color: #333; color: #333;
margin-bottom: 10px;
font-size: 28px; font-size: 28px;
text-align: center;
} }
.subtitle { .subtitle {
text-align: center;
color: #666; color: #666;
margin-bottom: 30px; margin-bottom: 30px;
font-size: 14px; font-size: 14px;
} }
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 20px;
}
.form-group { .form-group {
display: flex; margin-bottom: 20px;
flex-direction: column;
}
.form-group.full-width {
grid-column: 1 / -1;
} }
label { label {
display: block; display: block;
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 600; color: #555;
color: #444; font-weight: 500;
font-size: 14px; font-size: 14px;
} }
input[type="text"] { input[type="text"], textarea {
width: 100%; width: 100%;
padding: 12px; padding: 12px;
border: 2px solid #e0e0e0; border: 2px solid #e0e0e0;
border-radius: 6px; border-radius: 8px;
font-size: 14px; font-size: 14px;
transition: border-color 0.3s; transition: border-color 0.3s;
box-sizing: border-box;
} }
input[type="text"]:focus { input[type="text"]:focus, textarea:focus {
outline: none; outline: none;
border-color: #667eea; border-color: #667eea;
} }
textarea { textarea {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 13px;
font-family: 'Courier New', monospace;
resize: vertical; resize: vertical;
min-height: 200px; min-height: 120px;
transition: border-color 0.3s; font-family: 'Courier New', monospace;
box-sizing: border-box;
} }
textarea:focus { .helper-text {
outline: none;
border-color: #667eea;
}
.help-text {
font-size: 12px; font-size: 12px;
color: #777; color: #888;
margin-top: 5px; margin-top: 5px;
margin-bottom: 0;
font-style: italic;
} }
button { button {
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
border: none; border: none;
padding: 14px 30px; border-radius: 8px;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
border-radius: 6px;
cursor: pointer; cursor: pointer;
width: 100%;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
margin-top: 10px;
} }
button:hover { button:hover:not(:disabled) {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
button:active {
transform: translateY(0);
} }
button:disabled { button:disabled {
background: #ccc; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
transform: none;
} }
.loader-overlay { .loading {
position: fixed; display: none;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.loader-content {
background: white;
padding: 40px;
border-radius: 12px;
text-align: center; text-align: center;
max-width: 400px; padding: 30px;
margin: 0 20px; }
.loading.active {
display: block;
} }
.spinner { .spinner {
border: 6px solid #f3f3f3; border: 4px solid #f3f3f3;
border-top: 6px solid #667eea; border-top: 4px solid #667eea;
border-radius: 50%; border-radius: 50%;
width: 60px; width: 50px;
height: 60px; height: 50px;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
margin: 0 auto 20px; margin: 0 auto 20px;
} }
@@ -170,401 +128,280 @@
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
} }
.loader-text { .loading-text {
font-size: 18px;
color: #333;
font-weight: 600;
margin-bottom: 10px;
}
.loader-subtext {
font-size: 14px;
color: #666; color: #666;
}
.message {
padding: 15px;
border-radius: 6px;
margin-top: 20px;
font-size: 14px; font-size: 14px;
} }
.error {
background: #fee;
color: #c33;
border: 1px solid #fcc;
}
.success {
background: #efe;
color: #3a3;
border: 1px solid #cfc;
}
.warning {
background: #fef7e0;
color: #8a6d3b;
border: 1px solid #faebcc;
}
.results { .results {
margin-top: 20px; display: none;
margin-top: 30px;
padding: 20px; padding: 20px;
background: #f9f9f9; background: #f8f9fa;
border-radius: 6px; border-radius: 10px;
border: 1px solid #e0e0e0; }
.results.active {
display: block;
} }
.results h3 { .results h3 {
margin-top: 0;
color: #333; color: #333;
margin-bottom: 15px;
font-size: 18px; font-size: 18px;
} }
.track-list { .result-info {
list-style: none;
padding: 0;
margin: 10px 0;
}
.track-list li {
padding: 8px;
background: white; background: white;
margin-bottom: 5px; padding: 15px;
border-radius: 4px; border-radius: 8px;
font-size: 13px; margin-bottom: 15px;
color: #555;
word-break: break-word;
} }
.track-list li.skipped { .result-row {
background: #fff9e6; display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
}
.result-label {
color: #666;
font-weight: 500;
}
.result-value {
color: #333;
}
.tracks-list {
max-height: 300px;
overflow-y: auto;
}
.track-item {
background: white;
padding: 12px;
border-radius: 6px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.track-filename {
color: #333;
flex: 1;
}
.track-status {
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.track-status.created {
background: #d4edda;
color: #155724;
}
.track-status.skipped {
background: #fff3cd;
color: #856404; color: #856404;
} }
.track-list li.created { .error {
background: #e8f5e9; display: none;
color: #2e7d32; margin-top: 20px;
}
.download-info {
margin-top: 15px;
padding: 15px; padding: 15px;
background: #e8f4f8; background: #f8d7da;
border-radius: 6px; color: #721c24;
border-left: 4px solid #2196F3; border-radius: 8px;
word-break: break-all; font-size: 14px;
} }
.download-info strong { .error.active {
color: #1976D2; display: block;
}
.stats {
display: flex;
gap: 20px;
margin-top: 15px;
flex-wrap: wrap;
}
.stat-item {
flex: 1;
min-width: 120px;
padding: 10px;
background: white;
border-radius: 4px;
text-align: center;
}
.stat-number {
font-size: 24px;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 12px;
color: #666;
margin-top: 5px;
}
/* Mobile Responsive Styles */
@media screen and (max-width: 768px) {
body {
padding: 10px;
align-items: flex-start;
}
.container {
padding: 20px;
margin: 10px 0;
}
h1 {
font-size: 24px;
margin-bottom: 8px;
}
.subtitle {
font-size: 13px;
margin-bottom: 20px;
}
.form-row {
grid-template-columns: 1fr;
gap: 15px;
margin-bottom: 15px;
}
label {
font-size: 13px;
}
input[type="text"],
textarea {
font-size: 16px; /* Prevents zoom on iOS */
padding: 10px;
}
textarea {
min-height: 150px;
font-size: 12px;
}
.help-text {
font-size: 11px;
}
button {
padding: 12px 20px;
font-size: 15px;
}
.loader-content {
padding: 30px 20px;
max-width: 90%;
}
.loader-text {
font-size: 16px;
}
.loader-subtext {
font-size: 13px;
}
.message {
font-size: 13px;
padding: 12px;
}
.results {
padding: 15px;
}
.results h3 {
font-size: 16px;
}
.results h4 {
font-size: 14px;
}
.stats {
gap: 10px;
}
.stat-item {
min-width: 80px;
padding: 8px;
}
.stat-number {
font-size: 20px;
}
.stat-label {
font-size: 11px;
}
.track-list li {
font-size: 12px;
padding: 6px;
}
.download-info {
font-size: 12px;
padding: 12px;
}
}
@media screen and (max-width: 480px) {
.container {
padding: 15px;
}
h1 {
font-size: 20px;
}
.subtitle {
font-size: 12px;
}
.stat-item {
min-width: 70px;
}
.stat-number {
font-size: 18px;
}
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>🎵 YouTube Concert Splitter</h1> <h1>🎵 YouTube Concert Splitter</h1>
<p class="subtitle">Download and split concert videos into individual tracks</p> <p class="subtitle">Download and split concerts into individual tracks</p>
<form id="splitterForm"> <form id="splitForm">
<div class="form-group full-width">
<label for="youtube_url">YouTube URL:</label>
<input type="text"
id="youtube_url"
name="youtube_url"
placeholder="https://www.youtube.com/watch?v=..."
required>
</div>
<div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="artist">Artist Name:</label> <label for="youtube_url">YouTube URL *</label>
<input type="text" <input
id="artist" type="text"
name="artist" id="youtube_url"
placeholder="e.g., Bob Dylan" name="youtube_url"
required> placeholder="https://www.youtube.com/watch?v=..."
required
>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="album">Album Name:</label> <label for="artist">Artist Name *</label>
<input type="text" <input
id="album" type="text"
name="album" id="artist"
placeholder="e.g., Live at Madison Square Garden" name="artist"
required> placeholder="Artist Name"
required
>
</div> </div>
<div class="form-group">
<label for="album">Album/Concert Name *</label>
<input
type="text"
id="album"
name="album"
placeholder="Live at Madison Square Garden 2024"
required
>
</div>
<div class="form-group">
<label for="setlist">Setlist (optional - leave empty for single track)</label>
<textarea
id="setlist"
name="setlist"
placeholder="0:00 Opening Song&#10;3:45 Second Track&#10;7:20 Third Song&#10;..."
></textarea>
<div class="helper-text">Format: TIMESTAMP TITLE (one per line). Leave empty to save the entire video as a single track.</div>
</div>
<button type="submit">Split Concert</button>
</form>
<div class="loading" id="loading">
<div class="spinner"></div>
<div class="loading-text">Processing... This may take a few minutes.</div>
<div class="loading-text" style="margin-top: 10px; font-size: 12px;">Please be patient, downloading and converting can take time.</div>
</div> </div>
<div class="form-group full-width"> <div class="error" id="error"></div>
<label for="setlist">Setlist (timestamps and track titles) - Optional:</label>
<textarea id="setlist" <div class="results" id="results">
name="setlist" <h3>✅ Success!</h3>
placeholder="0:00 Opening Song&#10;3:45 Second Track&#10;7:30 Third Song&#10;&#10;(Leave empty to download as single track - no splitting)"></textarea> <div class="result-info">
<div class="help-text"> <div class="result-row">
<strong>Split Mode:</strong> Enter "TIMESTAMP TRACK_TITLE" per line (e.g., "0:00 Song Name")<br> <span class="result-label">Album:</span>
<strong>Single Mode:</strong> Leave this field completely empty to download entire video as one track <span class="result-value" id="resultAlbum"></span>
</div>
<div class="result-row">
<span class="result-label">Artist:</span>
<span class="result-value" id="resultArtist"></span>
</div>
<div class="result-row">
<span class="result-label">Total Tracks:</span>
<span class="result-value" id="resultTotal"></span>
</div>
<div class="result-row">
<span class="result-label">Created:</span>
<span class="result-value" id="resultCreated"></span>
</div>
<div class="result-row">
<span class="result-label">Skipped:</span>
<span class="result-value" id="resultSkipped"></span>
</div>
<div class="result-row">
<span class="result-label">Output Directory:</span>
<span class="result-value" id="resultDir"></span>
</div>
</div> </div>
<h3 style="margin-top: 20px;">Tracks</h3>
<div class="tracks-list" id="tracksList"></div>
</div> </div>
<button type="submit" id="submitBtn">Split Concert</button>
</form>
<div id="message"></div>
<div id="results"></div>
</div>
<div id="loader" class="loader-overlay" style="display: none;">
<div class="loader-content">
<div class="spinner"></div>
<div class="loader-text">Processing...</div>
<div class="loader-subtext">Downloading and splitting tracks. This may take several minutes.</div>
</div> </div>
</div>
<script> <script>
document.getElementById('splitterForm').addEventListener('submit', function(e) { document.getElementById('splitForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
// Clear previous messages const form = e.target;
document.getElementById('message').innerHTML = ''; const formData = new FormData(form);
document.getElementById('results').innerHTML = '';
// Show loader // Show loading, hide results and errors
document.getElementById('loader').style.display = 'flex'; document.getElementById('loading').classList.add('active');
document.getElementById('submitBtn').disabled = true; document.getElementById('results').classList.remove('active');
document.getElementById('error').classList.remove('active');
form.querySelector('button').disabled = true;
const url = document.getElementById('youtube_url').value; try {
const artist = document.getElementById('artist').value; // Increase timeout to 10 minutes (600000ms)
const album = document.getElementById('album').value; const controller = new AbortController();
const setlist = document.getElementById('setlist').value; const timeoutId = setTimeout(() => controller.abort(), 600000);
const formData = new FormData(); const response = await fetch('/split', {
formData.append('youtube_url', url); method: 'POST',
formData.append('artist', artist); body: formData,
formData.append('album', album); signal: controller.signal
formData.append('setlist', setlist); });
fetch('/split', { clearTimeout(timeoutId);
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
document.getElementById('loader').style.display = 'none';
document.getElementById('submitBtn').disabled = false;
if (data.error) { // Check if response is JSON
document.getElementById('message').innerHTML = const contentType = response.headers.get('content-type');
`<div class="message error"><strong>Error:</strong> ${data.error}</div>`; if (!contentType || !contentType.includes('application/json')) {
} else { throw new Error('Server returned non-JSON response. Check server logs.');
// Display success message with stats
let html = '';
if (data.skipped_count > 0) {
html += `<div class="message warning"><strong>Note:</strong> ${data.skipped_count} track(s) already existed and were skipped.</div>`;
}
html += `<div class="message success"><strong>Success!</strong> Processing complete.</div>`;
html += `<div class="results">`;
html += `<h3>🎸 ${data.artist} - ${data.album}</h3>`;
html += `<div class="stats">`;
html += `<div class="stat-item">`;
html += `<div class="stat-number">${data.created_count}</div>`;
html += `<div class="stat-label">Created</div>`;
html += `</div>`;
html += `<div class="stat-item">`;
html += `<div class="stat-number">${data.skipped_count}</div>`;
html += `<div class="stat-label">Skipped</div>`;
html += `</div>`;
html += `<div class="stat-item">`;
html += `<div class="stat-number">${data.total_tracks}</div>`;
html += `<div class="stat-label">Total Tracks</div>`;
html += `</div>`;
html += `</div>`;
html += `<div class="download-info"><strong>📁 Output Directory:</strong> ${data.output_dir}</div>`;
html += `<h4>Track Details:</h4>`;
html += `<ul class="track-list">`;
data.tracks.forEach(track => {
const className = track.status === 'created' ? 'created' : 'skipped';
const icon = track.status === 'created' ? '✓' : '⊘';
const statusText = track.status === 'created' ? 'Created' : 'Already exists';
html += `<li class="${className}">${icon} ${track.filename} <em>(${statusText})</em></li>`;
});
html += `</ul>`;
html += `</div>`;
document.getElementById('results').innerHTML = html;
} }
})
.catch(error => { const data = await response.json();
document.getElementById('loader').style.display = 'none';
document.getElementById('submitBtn').disabled = false; if (!response.ok) {
document.getElementById('message').innerHTML = throw new Error(data.error || 'Unknown error occurred');
`<div class="message error"><strong>Error:</strong> ${error.message}</div>`; }
});
}); if (data.success) {
</script> // Display results
document.getElementById('resultAlbum').textContent = data.album;
document.getElementById('resultArtist').textContent = data.artist;
document.getElementById('resultTotal').textContent = data.total_tracks;
document.getElementById('resultCreated').textContent = data.created_count;
document.getElementById('resultSkipped').textContent = data.skipped_count;
document.getElementById('resultDir').textContent = data.output_dir;
// Display tracks
const tracksList = document.getElementById('tracksList');
tracksList.innerHTML = '';
data.tracks.forEach(track => {
const trackItem = document.createElement('div');
trackItem.className = 'track-item';
trackItem.innerHTML = `
<span class="track-filename">${track.filename}</span>
<span class="track-status ${track.status}">${track.status}</span>
`;
tracksList.appendChild(trackItem);
});
document.getElementById('results').classList.add('active');
} else {
throw new Error(data.error || 'Processing failed');
}
} catch (error) {
console.error('Error:', error);
const errorDiv = document.getElementById('error');
if (error.name === 'AbortError') {
errorDiv.textContent = 'Request timed out. The download is taking too long. Please try with a shorter video or check your internet connection.';
} else {
errorDiv.textContent = error.message || 'An error occurred. Check the console for details.';
}
errorDiv.classList.add('active');
} finally {
document.getElementById('loading').classList.remove('active');
form.querySelector('button').disabled = false;
}
});
</script>
</body> </body>
</html> </html>