Keys commited on
Commit
ee683ef
·
1 Parent(s): ceb6514

Copied to github

Browse files
Files changed (8) hide show
  1. .gitignore +4 -0
  2. Dockerfile +20 -0
  3. Procfile +1 -0
  4. app.py +254 -0
  5. docker-compose.yaml +8 -0
  6. render.yaml +10 -0
  7. requirements.txt +8 -0
  8. templates/index.html +307 -0
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ .env
2
+ *.pyc
3
+ __pycache__/
4
+ .vercel
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use the official Python image from the Docker Hub
2
+ FROM python:3.9-alpine
3
+
4
+ # Set the working directory in the container
5
+ WORKDIR /app
6
+
7
+ # Copy the requirements.txt file to the container at /app
8
+ COPY requirements.txt .
9
+
10
+ # Install any dependencies specified in requirements.txt
11
+ RUN pip3 install --no-cache-dir -r requirements.txt
12
+
13
+ # Copy the current directory contents into the container at /app
14
+ COPY . .
15
+
16
+ # Expose port 5000 for the Flask app
17
+ EXPOSE 5100
18
+
19
+ # Command to run the Flask app
20
+ CMD ["flask", "run", "--host=0.0.0.0", "--port=5100"]
Procfile ADDED
@@ -0,0 +1 @@
 
 
1
+ web: gunicorn app:app
app.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ import logging
4
+ import re
5
+ import zipfile
6
+ import smtplib
7
+ from email.mime.text import MIMEText
8
+ from email.mime.multipart import MIMEMultipart
9
+ from email.mime.base import MIMEBase
10
+ from email import encoders
11
+ from dotenv import load_dotenv
12
+ from concurrent.futures import ThreadPoolExecutor, as_completed
13
+ import multiprocessing
14
+ from flask import Flask, render_template, request, jsonify
15
+ from googleapiclient.discovery import build
16
+ import yt_dlp
17
+ from pydub import AudioSegment
18
+
19
+ app = Flask(__name__, static_folder='static')
20
+
21
+
22
+ # Load API key and email credentials from .env file
23
+ load_dotenv()
24
+ api_key = os.getenv('YOUTUBE_API_KEY')
25
+ sender_email = os.getenv('SENDER_EMAIL')
26
+ email_password = os.getenv('EMAIL_PASSWORD')
27
+
28
+ # Validate environment variables
29
+ if not all([api_key, sender_email, email_password]):
30
+ raise ValueError("Missing environment variables. Please check your .env file.")
31
+
32
+ # Determine the number of CPU cores available
33
+ num_cores = multiprocessing.cpu_count()
34
+
35
+ # Function to validate email
36
+ def is_valid_email(email):
37
+ pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
38
+ return re.match(pattern, email) is not None
39
+
40
+ # Function to get YouTube links
41
+ def get_youtube_links(api_key, query, max_results=20):
42
+ try:
43
+ youtube = build('youtube', 'v3', developerKey=api_key)
44
+ search_response = youtube.search().list(
45
+ q=query,
46
+ part='snippet',
47
+ type='video',
48
+ maxResults=max_results
49
+ ).execute()
50
+
51
+ videos = []
52
+ for item in search_response['items']:
53
+ video_id = item['id']['videoId']
54
+ video_title = item['snippet']['title']
55
+ video_url = f"https://www.youtube.com/watch?v={video_id}"
56
+ videos.append((video_title, video_url))
57
+
58
+ return videos
59
+ except Exception as e:
60
+ logging.error(f"Failed to fetch YouTube links: {e}")
61
+ return []
62
+
63
+ # Function to download audio from YouTube
64
+ def download_single_audio(url, index, download_path):
65
+ ydl_opts = {
66
+ 'format': 'bestaudio/best',
67
+ 'outtmpl': f'{download_path}/song_{index}_%(title)s.%(ext)s',
68
+ 'postprocessors': [{
69
+ 'key': 'FFmpegExtractAudio',
70
+ 'preferredcodec': 'mp3',
71
+ 'preferredquality': '192',
72
+ }],
73
+ 'retries': 10,
74
+ 'fragment_retries': 10,
75
+ }
76
+
77
+ try:
78
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
79
+ ydl.download([url])
80
+ downloaded_files = [f for f in os.listdir(download_path) if f.startswith(f"song_{index}_") and f.endswith(".mp3")]
81
+ if downloaded_files:
82
+ return os.path.join(download_path, downloaded_files[0])
83
+ else:
84
+ logging.error(f"Downloaded file not found for {url}")
85
+ return None
86
+ except Exception as e:
87
+ logging.error(f"Error downloading audio: {e}")
88
+ return None
89
+
90
+ # Function to download all audio files in parallel
91
+ def download_all_audio(video_urls, download_path):
92
+ downloaded_files = []
93
+ with ThreadPoolExecutor(max_workers=num_cores) as executor:
94
+ futures = {
95
+ executor.submit(download_single_audio, url, index, download_path): index
96
+ for index, url in enumerate(video_urls, start=1)
97
+ }
98
+
99
+ for future in as_completed(futures):
100
+ try:
101
+ mp3_file = future.result()
102
+ if mp3_file:
103
+ downloaded_files.append(mp3_file)
104
+ except Exception as e:
105
+ logging.error(f"Error occurred: {e}")
106
+
107
+ return downloaded_files
108
+
109
+ # Function to create a mashup from audio files
110
+ def create_mashup(audio_files, output_file, trim_duration):
111
+ mashup = AudioSegment.silent(duration=0)
112
+ total_trim_duration_per_file = trim_duration * 1000 # Convert to milliseconds
113
+
114
+ for file in audio_files:
115
+ try:
116
+ audio = AudioSegment.from_file(file)
117
+ if len(audio) < total_trim_duration_per_file:
118
+ logging.warning(f"Audio file {file} is shorter than trim duration. Using full length.")
119
+ part = audio
120
+ else:
121
+ part = audio[:total_trim_duration_per_file]
122
+ mashup += part
123
+ except Exception as e:
124
+ logging.error(f"Error processing file {file}: {e}")
125
+
126
+ if len(mashup) == 0:
127
+ logging.error("No audio files were successfully processed.")
128
+ return None
129
+
130
+ expected_mashup_duration = total_trim_duration_per_file * len(audio_files)
131
+ if len(mashup) < expected_mashup_duration:
132
+ logging.warning(f"Mashup duration ({len(mashup)}ms) is less than expected ({expected_mashup_duration}ms).")
133
+ else:
134
+ mashup = mashup[:expected_mashup_duration]
135
+
136
+ mashup.export(output_file, format="mp3", bitrate="128k")
137
+ return output_file
138
+
139
+ def create_zip_file(file_path, zip_path):
140
+ with zipfile.ZipFile(zip_path, 'w') as zipf:
141
+ zipf.write(file_path, os.path.basename(file_path))
142
+ return zip_path
143
+
144
+ # Function to send email
145
+ def send_email(sender_email, receiver_email, subject, body, attachment_path, password):
146
+ try:
147
+ msg = MIMEMultipart()
148
+ msg['From'] = sender_email
149
+ msg['To'] = receiver_email
150
+ msg['Subject'] = subject
151
+
152
+ msg.attach(MIMEText(body, 'plain'))
153
+
154
+ with open(attachment_path, 'rb') as attachment:
155
+ part = MIMEBase('application', 'zip')
156
+ part.set_payload(attachment.read())
157
+ encoders.encode_base64(part)
158
+ part.add_header('Content-Disposition', f"attachment; filename= {os.path.basename(attachment_path)}")
159
+ msg.attach(part)
160
+
161
+ server = smtplib.SMTP('smtp.gmail.com', 587)
162
+ server.starttls()
163
+ server.login(sender_email, password)
164
+ text = msg.as_string()
165
+ server.sendmail(sender_email, receiver_email, text)
166
+ server.quit()
167
+
168
+ logging.info("Email sent successfully!")
169
+ return True
170
+ except Exception as e:
171
+ logging.error(f"Failed to send email: {e}")
172
+ return False
173
+
174
+ @app.route('/')
175
+ def index():
176
+ return render_template('index.html')
177
+
178
+ @app.route('/create_mashup', methods=['POST'])
179
+ def create_mashup_route():
180
+ try:
181
+ # Log incoming request data
182
+ logging.info(f"Received create_mashup request: {request.form}")
183
+
184
+ # Validate and extract form data
185
+ singer_name = request.form.get('singer_name')
186
+ num_videos = request.form.get('num_videos')
187
+ trim_duration = request.form.get('trim_duration')
188
+ receiver_email = request.form.get('receiver_email')
189
+
190
+ # Validate required fields
191
+ if not all([singer_name, num_videos, trim_duration, receiver_email]):
192
+ missing_fields = [field for field in ['singer_name', 'num_videos', 'trim_duration', 'receiver_email'] if not request.form.get(field)]
193
+ return jsonify({'status': 'error', 'message': f'Missing required fields: {", ".join(missing_fields)}'})
194
+
195
+ # Validate and convert numeric fields
196
+ try:
197
+ num_videos = max(int(num_videos), 10)
198
+ trim_duration = max(int(trim_duration), 20)
199
+ except ValueError:
200
+ return jsonify({'status': 'error', 'message': 'Invalid numeric values for num_videos or trim_duration'})
201
+
202
+ # Validate email
203
+ if not is_valid_email(receiver_email):
204
+ return jsonify({'status': 'error', 'message': 'Please enter a valid email address.'})
205
+
206
+ # Fetch YouTube links
207
+ logging.info(f"Fetching YouTube links for {singer_name}")
208
+ videos = get_youtube_links(api_key, singer_name, max_results=num_videos)
209
+
210
+ if not videos:
211
+ return jsonify({'status': 'error', 'message': f'No videos found for {singer_name}. Please try a different singer name.'})
212
+
213
+ # Create temporary directory
214
+ with tempfile.TemporaryDirectory() as download_path:
215
+ logging.info(f"Created temporary directory: {download_path}")
216
+
217
+ # Download audio files
218
+ video_urls = [url for _, url in videos]
219
+ logging.info(f"Downloading {len(video_urls)} audio files")
220
+ audio_files = download_all_audio(video_urls, download_path)
221
+
222
+ if not audio_files:
223
+ return jsonify({'status': 'error', 'message': 'Failed to download audio files. Please try again.'})
224
+
225
+ # Create mashup
226
+ logging.info("Creating mashup")
227
+ output_file = os.path.join(download_path, "mashup.mp3")
228
+ mashup_file = create_mashup(audio_files, output_file, trim_duration)
229
+
230
+ if not mashup_file:
231
+ return jsonify({'status': 'error', 'message': 'Failed to create mashup. Please try again.'})
232
+
233
+ # Create zip file
234
+ zip_file = os.path.join(download_path, "mashup.zip")
235
+ create_zip_file(mashup_file, zip_file)
236
+
237
+ # Send email
238
+ logging.info(f"Sending email to {receiver_email}")
239
+ subject = f"Your {singer_name} YouTube Mashup"
240
+ body = f"Please find attached your custom YouTube mashup of {singer_name} songs. Duration: {trim_duration * len(audio_files)} seconds."
241
+ email_sent = send_email(sender_email, receiver_email, subject, body, zip_file, email_password)
242
+
243
+ if email_sent:
244
+ return jsonify({'status': 'success', 'message': 'Mashup created and sent successfully! Check your email.'})
245
+ else:
246
+ return jsonify({'status': 'error', 'message': 'Mashup created but failed to send email. Please try again.'})
247
+
248
+ except Exception as e:
249
+ logging.error(f"Unexpected error in create_mashup_route: {str(e)}", exc_info=True)
250
+ return jsonify({'status': 'error', 'message': f'An unexpected error occurred: {str(e)}'})
251
+
252
+ if __name__ == '__main__':
253
+ port = int(os.environ.get('PORT', 5000))
254
+ app.run(host='0.0.0.0', port=port)
docker-compose.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ version: "3.8"
2
+ services:
3
+ flask-app:
4
+ build: .
5
+ ports:
6
+ - "5100:5100"
7
+ env_file:
8
+ - .env
render.yaml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: youtube-mashup-creator
4
+ buildCommand: pip install -r requirements.txt
5
+ startCommand: gunicorn app:app
6
+ envVars:
7
+ - key: PYTHON_VERSION
8
+ value: 3.8.0
9
+ packages:
10
+ - ffmpeg
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ Flask==2.2.5
2
+ Werkzeug==2.2.3
3
+ flask-cors==3.0.10
4
+ python-dotenv==1.0.0
5
+ google-api-python-client==2.86.0
6
+ yt-dlp==2023.3.4
7
+ pydub==0.25.1
8
+ gunicorn==20.1.0
templates/index.html ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>YouTube Mashup Creator</title>
7
+ <link
8
+ href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css"
9
+ rel="stylesheet"
10
+ />
11
+ <style>
12
+ :root {
13
+ --primary-color: #4a90e2;
14
+ --secondary-color: #f39c12;
15
+ --background-color: #f4f4f4;
16
+ --text-color: #333;
17
+ }
18
+
19
+ body {
20
+ font-family: "Arial", sans-serif;
21
+ line-height: 1.6;
22
+ margin: 0;
23
+ padding: 0;
24
+ background-color: var(--background-color);
25
+ color: var(--text-color);
26
+ }
27
+
28
+ .container {
29
+ max-width: 800px;
30
+ margin: 2rem auto;
31
+ padding: 2rem;
32
+ background-color: #fff;
33
+ border-radius: 10px;
34
+ box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
35
+ }
36
+
37
+ h1 {
38
+ color: var(--primary-color);
39
+ text-align: center;
40
+ margin-bottom: 2rem;
41
+ font-size: 2.5rem;
42
+ }
43
+
44
+ .form-group {
45
+ margin-bottom: 1.5rem;
46
+ }
47
+
48
+ label {
49
+ display: block;
50
+ margin-bottom: 0.5rem;
51
+ font-weight: bold;
52
+ }
53
+
54
+ input[type="text"],
55
+ input[type="number"],
56
+ input[type="email"] {
57
+ width: 100%;
58
+ padding: 0.8rem;
59
+ border: 1px solid #ddd;
60
+ border-radius: 4px;
61
+ font-size: 1rem;
62
+ }
63
+
64
+ button {
65
+ display: block;
66
+ width: 100%;
67
+ padding: 1rem;
68
+ background-color: var(--primary-color);
69
+ color: #fff;
70
+ border: none;
71
+ border-radius: 4px;
72
+ cursor: pointer;
73
+ font-size: 1.1rem;
74
+ transition: background-color 0.3s ease;
75
+ }
76
+
77
+ button:hover {
78
+ background-color: #3570b8;
79
+ }
80
+
81
+ .loader {
82
+ border: 5px solid #f3f3f3;
83
+ border-top: 5px solid var(--secondary-color);
84
+ border-radius: 50%;
85
+ width: 50px;
86
+ height: 50px;
87
+ animation: spin 1s linear infinite;
88
+ margin: 20px auto;
89
+ }
90
+
91
+ @keyframes spin {
92
+ 0% {
93
+ transform: rotate(0deg);
94
+ }
95
+ 100% {
96
+ transform: rotate(360deg);
97
+ }
98
+ }
99
+
100
+ .message {
101
+ margin-top: 1.5rem;
102
+ padding: 1rem;
103
+ border-radius: 4px;
104
+ text-align: center;
105
+ font-weight: bold;
106
+ }
107
+
108
+ .message.success {
109
+ background-color: #d4edda;
110
+ color: #155724;
111
+ }
112
+
113
+ .message.error {
114
+ background-color: #f8d7da;
115
+ color: #721c24;
116
+ }
117
+
118
+ .info-icon {
119
+ color: var(--primary-color);
120
+ margin-left: 0.5rem;
121
+ cursor: help;
122
+ }
123
+
124
+ .tooltip {
125
+ position: relative;
126
+ display: inline-block;
127
+ }
128
+
129
+ .tooltip .tooltiptext {
130
+ visibility: hidden;
131
+ width: 200px;
132
+ background-color: #555;
133
+ color: #fff;
134
+ text-align: center;
135
+ border-radius: 6px;
136
+ padding: 5px;
137
+ position: absolute;
138
+ z-index: 1;
139
+ bottom: 125%;
140
+ left: 50%;
141
+ margin-left: -100px;
142
+ opacity: 0;
143
+ transition: opacity 0.3s;
144
+ }
145
+
146
+ .tooltip:hover .tooltiptext {
147
+ visibility: visible;
148
+ opacity: 1;
149
+ }
150
+ </style>
151
+ </head>
152
+ <body>
153
+ <div class="container">
154
+ <h1><i class="fas fa-music"></i> YouTube Mashup Creator</h1>
155
+ <form id="mashupForm">
156
+ <div class="form-group">
157
+ <label for="singer_name">Singer name:</label>
158
+ <input
159
+ type="text"
160
+ id="singer_name"
161
+ name="singer_name"
162
+ required
163
+ />
164
+ </div>
165
+
166
+ <div class="form-group">
167
+ <label for="num_videos">Number of videos (10-30):</label>
168
+ <input
169
+ type="number"
170
+ id="num_videos"
171
+ name="num_videos"
172
+ min="10"
173
+ max="30"
174
+ value="10"
175
+ required
176
+ />
177
+ <span class="tooltip">
178
+ <i class="fas fa-info-circle info-icon"></i>
179
+ <span class="tooltiptext"
180
+ >Choose between 10 and 30 videos for your
181
+ mashup.</span
182
+ >
183
+ </span>
184
+ </div>
185
+
186
+ <div class="form-group">
187
+ <label for="trim_duration"
188
+ >Trim duration for each video (20-500 seconds):</label
189
+ >
190
+ <input
191
+ type="number"
192
+ id="trim_duration"
193
+ name="trim_duration"
194
+ min="20"
195
+ max="500"
196
+ value="20"
197
+ required
198
+ />
199
+ <span class="tooltip">
200
+ <i class="fas fa-info-circle info-icon"></i>
201
+ <span class="tooltiptext"
202
+ >Choose a trim duration between 20 and 500 seconds
203
+ for each video clip.</span
204
+ >
205
+ </span>
206
+ </div>
207
+
208
+ <div class="form-group">
209
+ <label for="receiver_email">Your email address:</label>
210
+ <input
211
+ type="email"
212
+ id="receiver_email"
213
+ name="receiver_email"
214
+ required
215
+ />
216
+ </div>
217
+
218
+ <button type="submit">
219
+ <i class="fas fa-magic"></i> Create and Send Mashup
220
+ </button>
221
+ </form>
222
+
223
+ <div id="loader" class="loader" style="display: none"></div>
224
+
225
+ <div id="message" class="message" style="display: none"></div>
226
+ </div>
227
+
228
+ <script>
229
+ document.addEventListener("DOMContentLoaded", function () {
230
+ const form = document.getElementById("mashupForm");
231
+ const loader = document.getElementById("loader");
232
+ const message = document.getElementById("message");
233
+
234
+ function validateEmail(email) {
235
+ const re =
236
+ /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
237
+ return re.test(String(email).toLowerCase());
238
+ }
239
+
240
+ form.addEventListener("submit", function (e) {
241
+ e.preventDefault();
242
+
243
+ const email = form.querySelector("#receiver_email").value;
244
+ if (!validateEmail(email)) {
245
+ message.textContent =
246
+ "Please enter a valid email address.";
247
+ message.className = "message error";
248
+ message.style.display = "block";
249
+ return;
250
+ }
251
+
252
+ // Show loader
253
+ loader.style.display = "block";
254
+ message.style.display = "none";
255
+
256
+ // Disable submit button
257
+ form.querySelector('button[type="submit"]').disabled = true;
258
+
259
+ // Create FormData object
260
+ const formData = new FormData(form);
261
+
262
+ // Send AJAX request
263
+ fetch("/create_mashup", {
264
+ method: "POST",
265
+ body: formData,
266
+ })
267
+ .then((response) => response.json())
268
+ .then((data) => {
269
+ // Hide loader
270
+ loader.style.display = "none";
271
+
272
+ // Show message
273
+ message.textContent = data.message;
274
+ message.className =
275
+ "message " +
276
+ (data.status === "success"
277
+ ? "success"
278
+ : "error");
279
+ message.style.display = "block";
280
+
281
+ // Enable submit button
282
+ form.querySelector(
283
+ 'button[type="submit"]',
284
+ ).disabled = false;
285
+ })
286
+ .catch((error) => {
287
+ console.error("Error:", error);
288
+
289
+ // Hide loader
290
+ loader.style.display = "none";
291
+
292
+ // Show error message
293
+ message.textContent =
294
+ "An error occurred. Please try again.";
295
+ message.className = "message error";
296
+ message.style.display = "block";
297
+
298
+ // Enable submit button
299
+ form.querySelector(
300
+ 'button[type="submit"]',
301
+ ).disabled = false;
302
+ });
303
+ });
304
+ });
305
+ </script>
306
+ </body>
307
+ </html>