├── .env ├── Dockerfile ├── README.md ├── app ├── main.py └── requirements.txt ├── docker-compose.yml └── web ├── index.html ├── script.js └── style.css /.env: -------------------------------------------------------------------------------- 1 | AUDIO_DOWNLOAD_PATH=/your/folder/path 2 | 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | # Use a base image with Python 3 | FROM python:3.12-alpine 4 | ENV PYTHONUNBUFFERED=1 5 | 6 | # Install dependencies 7 | RUN apk add --no-cache ca-certificates curl python3-dev git ffmpeg 8 | 9 | # Set up a virtual environment for dependencies 10 | RUN python3 -m venv /opt/venv 11 | ENV PATH="/opt/venv/bin:$PATH" 12 | 13 | # Install Python packages 14 | RUN pip install --no-cache-dir flask spotdl 15 | 16 | # Create directories 17 | WORKDIR /app 18 | RUN mkdir -p /app/downloads 19 | 20 | # Copy application code 21 | COPY app /app 22 | COPY web /app/web 23 | 24 | # Expose the application port 25 | EXPOSE 5000 26 | 27 | # Run the application 28 | CMD ["python3", "/app/main.py"] 29 | 30 | 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Audio Downloader 2 | 3 | A self-hosted web application for downloading songs, albums, or playlists from Spotify and YouTube as MP3 files. The application provides a web interface for users to input links, which are then downloaded as audio files using `spotdl` (for Spotify) or `yt-dlp` (for YouTube). 4 | 5 | ## Features 6 | 7 | - **Download Spotify and YouTube playlists**: Automatically detects and processes playlists based on the URL. 8 | - **Session-based download directories**: Isolates each user session to a unique download directory. 9 | - **Admin Mode**: Enables admin users to download directly to a specified folder on the server. 10 | - **Progress bar and download logs**: View download progress and logs in real-time via the web interface. 11 | - **Auto-cleanup**: Deletes temporary session download folders after a specified time. 12 | - **Organized Downloads**: Downloads are structured by artist and album folders, maintaining organization across downloads. 13 | 15 | ## Prerequisites 16 | 17 | - **Docker** and **Docker Compose** installed on your system. 18 | 19 | ## Installation 20 | 21 | **Run with Docker Compose**: 22 | Use the provided `docker-compose.yaml` configuration to start the container. 23 | ```yaml 24 | services: 25 | playlistdl: 26 | image: tanner23456/playlistdl:v2 27 | container_name: playlistdl 28 | ports: 29 | - "4827:5000" 30 | environment: 31 | #Direct Server Download 32 | - ADMIN_USERNAME= #Insert unique username here! 33 | - ADMIN_PASSWORD= #Insert unique password here! 34 | 35 | - AUDIO_DOWNLOAD_PATH=${AUDIO_DOWNLOAD_PATH} # Use the env variable 36 | - CLEANUP_INTERVAL=300 # Optional 37 | volumes: 38 | - ${AUDIO_DOWNLOAD_PATH}:${AUDIO_DOWNLOAD_PATH} # Reference env variable here as well 39 | 40 | 41 | ``` 42 | 43 | ## Usage 44 | 45 | 1. **Access the Web Interface**: 46 | Open a browser and navigate to `http://localhost:5000` (replace `localhost` with your server IP if remote). 47 | 48 | 2. **Download a Playlist**: 49 | - Enter a Spotify or YouTube playlist URL. 50 | - Click **Download** to start the process. 51 | - Monitor download progress and logs via the interface. 52 | 3. **Admin Mode**: 53 | - Click the **Admin** button to log in with your credentials. 54 | - Once logged in, a message will appear in red indicating, "Now downloading directly to your server!" 55 | - Enter the playlist or album link as usual, and files will be saved to the designated admin folder on your server. 56 | - The UI will also dispaly a text box to input a folder path. This is to alter the path the files are saved to without having to restart the container. The folder can only be changed to another folder within the mounted directory. 57 | 62 | ## Configuration 63 | 64 | ### Environment Variables 65 | 66 | - `CLEANUP_INTERVAL`: (Optional) Sets the cleanup interval for session-based download folders. Defaults to `300` seconds (5 minutes) if not specified. 67 | - `ADMIN_USERNAME` and `ADMIN_PASSWORD`:(Optional) Sets the login credentials for admin access. 68 | - `AUDIO_DOWNLOAD_PATH`: Sets the folder for admin-mode downloads. Files downloaded as an admin are stored here. This is set in your .env file. 69 | 70 | ## Technical Overview 71 | 72 | - **Backend**: Flask application that handles download requests and manages session-based directories. 73 | - **Frontend**: Simple HTML/JavaScript interface for input, progress display, and log viewing. 74 | - **Tools**: 75 | - `spotdl` for downloading Spotify playlists. 76 | - `yt-dlp` for downloading YouTube playlists as MP3s. 77 | 78 | ## Notes 79 | 80 | - This application is intended for personal use. Make sure to follow copyright laws and only download media you’re authorized to use. 81 | - Ensure that the `downloads` directory has appropriate permissions if running on a remote server. 82 | 83 | ## Troubleshooting 84 | 85 | - **Permissions**: Ensure the `downloads` directory has the correct permissions for Docker to write files. 86 | - **Port Conflicts**: If port 5000 is in use, adjust the port mapping in the `docker-compose.yaml` file. 87 | 88 | ## Support This Project 89 | 90 | If you like this project, consider supporting it with a donation! 91 | 92 | [![Donate via Stripe](https://img.shields.io/badge/Donate-Stripe-blue?style=flat&logo=stripe)](https://buy.stripe.com/6oEdU3dWS19C556dQQ) 93 | 94 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, send_from_directory, jsonify, request, Response 2 | import subprocess 3 | import os 4 | import zipfile 5 | import uuid 6 | import shutil 7 | import threading 8 | import time 9 | import re # Add regex for capturing album/playlist name 10 | 11 | app = Flask(__name__, static_folder='web') 12 | BASE_DOWNLOAD_FOLDER = '/app/downloads' 13 | AUDIO_DOWNLOAD_PATH = os.getenv('AUDIO_DOWNLOAD_PATH', BASE_DOWNLOAD_FOLDER) 14 | ADMIN_USERNAME = os.getenv('ADMIN_USERNAME') 15 | ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD') 16 | ADMIN_DOWNLOAD_PATH = AUDIO_DOWNLOAD_PATH # default to .env path 17 | 18 | sessions = {} 19 | 20 | os.makedirs(BASE_DOWNLOAD_FOLDER, exist_ok=True) 21 | 22 | @app.route('/') 23 | def serve_index(): 24 | return send_from_directory(app.static_folder, 'index.html') 25 | 26 | @app.route('/') 27 | def serve_static(path): 28 | return send_from_directory(app.static_folder, path) 29 | 30 | @app.route('/login', methods=['POST']) 31 | def login(): 32 | data = request.get_json() 33 | username = data.get('username') 34 | password = data.get('password') 35 | if username == ADMIN_USERNAME and password == ADMIN_PASSWORD: 36 | session_id = str(uuid.uuid4()) 37 | sessions[session_id] = username 38 | response = jsonify({"success": True}) 39 | response.set_cookie('session', session_id) 40 | return response 41 | return jsonify({"success": False}), 401 42 | 43 | def is_logged_in(): 44 | session_id = request.cookies.get('session') 45 | return session_id in sessions 46 | 47 | @app.route('/logout', methods=['POST']) 48 | def logout(): 49 | response = jsonify({"success": True}) 50 | response.delete_cookie('session') # Remove session cookie 51 | return response 52 | 53 | @app.route('/check-login') 54 | def check_login(): 55 | is_logged_in_status = is_logged_in() 56 | return jsonify({"loggedIn": is_logged_in_status}) 57 | 58 | 59 | @app.route('/download') 60 | def download_media(): 61 | spotify_link = request.args.get('spotify_link') 62 | if not spotify_link: 63 | return jsonify({"status": "error", "output": "No link provided"}), 400 64 | 65 | session_id = str(uuid.uuid4()) 66 | temp_download_folder = os.path.join(BASE_DOWNLOAD_FOLDER, session_id) 67 | os.makedirs(temp_download_folder, exist_ok=True) 68 | 69 | if "spotify" in spotify_link: 70 | command = [ 71 | 'spotdl', 72 | '--output', f"{temp_download_folder}/{{artist}}/{{album}}/{{title}}.{{output-ext}}", 73 | spotify_link 74 | ] 75 | else: 76 | command = [ 77 | 'yt-dlp', '-x', '--audio-format', 'mp3', 78 | '-o', f"{temp_download_folder}/%(uploader)s/%(album)s/%(title)s.%(ext)s", 79 | spotify_link 80 | ] 81 | 82 | is_admin = is_logged_in() 83 | return Response(generate(is_admin, command, temp_download_folder, session_id), mimetype='text/event-stream') 84 | 85 | def generate(is_admin, command, temp_download_folder, session_id): 86 | album_name = None 87 | try: 88 | print(f"🎧 Command being run: {' '.join(command)}") 89 | print(f"📁 Temp download folder: {temp_download_folder}") 90 | 91 | process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) 92 | 93 | for line in process.stdout: 94 | print(f"▶️ {line.strip()}") 95 | yield f"data: {line.strip()}\n\n" 96 | 97 | # Capture album name for zipping later 98 | match = re.search(r'Found \d+ songs in (.+?) \(', line) 99 | if match: 100 | album_name = match.group(1).strip() 101 | 102 | process.stdout.close() 103 | process.wait() 104 | 105 | if process.returncode != 0: 106 | yield f"data: Error: Download exited with code {process.returncode}.\n\n" 107 | return 108 | 109 | # Gather all downloaded audio files 110 | downloaded_files = [] 111 | for root, _, files in os.walk(temp_download_folder): 112 | for file in files: 113 | full_path = os.path.join(root, file) 114 | print(f"📄 Found file: {full_path}") 115 | downloaded_files.append(full_path) 116 | 117 | valid_audio_files = [f for f in downloaded_files if f.lower().endswith(('.mp3', '.m4a', '.flac', '.wav', '.ogg'))] 118 | 119 | if not valid_audio_files: 120 | yield f"data: Error: No valid audio files found. Please check the link.\n\n" 121 | return 122 | 123 | # ✅ ADMIN HANDLING 124 | if is_admin: 125 | for file_path in valid_audio_files: 126 | filename = os.path.basename(file_path) 127 | 128 | if 'General Conference' in filename and '|' in filename: 129 | speaker_name = filename.split('|')[0].strip() 130 | target_path = os.path.join(ADMIN_DOWNLOAD_PATH, speaker_name, filename) 131 | print(f"🚚 Moving GC file to: {target_path}") 132 | else: 133 | relative_path = os.path.relpath(file_path, temp_download_folder) 134 | target_path = os.path.join(ADMIN_DOWNLOAD_PATH, relative_path) 135 | print(f"🚚 Moving to default admin path: {target_path}") 136 | 137 | os.makedirs(os.path.dirname(target_path), exist_ok=True) 138 | try: 139 | shutil.move(file_path, target_path) 140 | except Exception as move_error: 141 | print(f"❌ Failed to move {file_path} to {target_path}: {move_error}") 142 | 143 | 144 | shutil.rmtree(temp_download_folder, ignore_errors=True) 145 | yield "data: Download completed. Files saved to server directory.\n\n" 146 | return # ✅ Don’t try to serve/move anything else 147 | 148 | # ✅ PUBLIC USER HANDLING 149 | if len(valid_audio_files) > 1: 150 | zip_filename = f"{album_name}.zip" if album_name else "playlist.zip" 151 | zip_path = os.path.join(temp_download_folder, zip_filename) 152 | with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: 153 | for file_path in valid_audio_files: 154 | arcname = os.path.relpath(file_path, start=temp_download_folder) 155 | zipf.write(file_path, arcname=arcname) 156 | 157 | yield f"data: DOWNLOAD: {session_id}/{zip_filename}\n\n" 158 | 159 | else: 160 | from urllib.parse import quote 161 | relative_path = os.path.relpath(valid_audio_files[0], start=temp_download_folder) 162 | encoded_path = quote(relative_path) 163 | yield f"data: DOWNLOAD: {session_id}/{encoded_path}\n\n" 164 | 165 | # Schedule cleanup of the temp folder 166 | threading.Thread(target=delayed_delete, args=(temp_download_folder,)).start() 167 | 168 | except Exception as e: 169 | yield f"data: Error: {str(e)}\n\n" 170 | 171 | 172 | def delayed_delete(folder_path): 173 | time.sleep(300) 174 | shutil.rmtree(folder_path, ignore_errors=True) 175 | 176 | def emergency_cleanup_container_downloads(): 177 | print("🚨 Running backup cleanup in /app/downloads") 178 | for folder in os.listdir(BASE_DOWNLOAD_FOLDER): 179 | folder_path = os.path.join(BASE_DOWNLOAD_FOLDER, folder) 180 | try: 181 | shutil.rmtree(folder_path) 182 | print(f"🗑️ Cleaned: {folder_path}") 183 | except Exception as e: 184 | print(f"⚠️ Could not delete {folder_path}: {e}") 185 | 186 | def schedule_emergency_cleanup(interval_seconds=3600): 187 | def loop(): 188 | while True: 189 | time.sleep(interval_seconds) 190 | emergency_cleanup_container_downloads() 191 | 192 | threading.Thread(target=loop, daemon=True).start() 193 | 194 | @app.route('/set-download-path', methods=['POST']) 195 | def set_download_path(): 196 | global ADMIN_DOWNLOAD_PATH 197 | if not is_logged_in(): 198 | return jsonify({"success": False, "message": "Unauthorized"}), 401 199 | 200 | data = request.get_json() 201 | new_path = data.get('path') 202 | 203 | if not new_path: 204 | return jsonify({"success": False, "message": "Path cannot be empty."}), 400 205 | 206 | # Optional: Validate the path, ensure it exists 207 | if not os.path.isdir(new_path): 208 | try: 209 | os.makedirs(new_path, exist_ok=True) 210 | except Exception as e: 211 | return jsonify({"success": False, "message": f"Cannot create path: {str(e)}"}), 500 212 | 213 | ADMIN_DOWNLOAD_PATH = new_path 214 | return jsonify({"success": True, "new_path": ADMIN_DOWNLOAD_PATH}) 215 | 216 | 217 | @app.route('/downloads//') 218 | def serve_download(session_id, filename): 219 | session_download_folder = os.path.join(BASE_DOWNLOAD_FOLDER, session_id) 220 | full_path = os.path.join(session_download_folder, filename) 221 | 222 | print(f"📥 Requested filename: {filename}") 223 | print(f"📁 Resolved full path: {full_path}") 224 | 225 | if ".." in filename or filename.startswith("/"): 226 | return "Invalid filename", 400 227 | 228 | if not os.path.isfile(full_path): 229 | print("❌ File does not exist!") 230 | return "File not found", 404 231 | 232 | return send_from_directory(session_download_folder, filename, as_attachment=True) 233 | 234 | schedule_emergency_cleanup() 235 | if __name__ == '__main__': 236 | app.run(host='0.0.0.0', port=5000) 237 | 238 | -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | spotdl 3 | 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | playlistdl: 4 | image: tanner23456/playlistdl:v2 5 | container_name: playlistdl 6 | ports: 7 | - "5005:5000" 8 | environment: 9 | #Direct Server Download 10 | - ADMIN_USERNAME=admin #Insert unique username here! 11 | - ADMIN_PASSWORD=password #Insert unique password here! 12 | 13 | - AUDIO_DOWNLOAD_PATH=${AUDIO_DOWNLOAD_PATH} # Use the env variable 14 | - CLEANUP_INTERVAL=300 # Optional 15 | volumes: 16 | - ${AUDIO_DOWNLOAD_PATH}:${AUDIO_DOWNLOAD_PATH} # Reference env variable here as well 17 | restart: unless-stopped 18 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Audio Downloader 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 |

Audio Downloader

15 | 19 | 20 | 21 | 22 | 23 | 24 | 29 | 30 |
31 | 32 | 33 |
34 |
35 | 36 | 37 | 38 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /web/script.js: -------------------------------------------------------------------------------- 1 | console.log("Script loaded"); // Add this line at the beginning of script.js 2 | 3 | async function download() { 4 | const spotifyLink = document.getElementById('spotifyLink').value; 5 | 6 | if (!spotifyLink) { 7 | document.getElementById('result').innerText = "Please enter a Spotify link."; 8 | return; 9 | } 10 | 11 | // Clear previous logs and result 12 | const logsElement = document.getElementById('logs'); 13 | logsElement.innerHTML = ""; 14 | document.getElementById('result').innerText = ""; 15 | 16 | // Show and reset the progress bar 17 | const progressBar = document.getElementById('progress'); 18 | progressBar.style.display = 'block'; 19 | progressBar.value = 0; 20 | const increment = 10; // Smaller increment for more gradual progress 21 | 22 | // Create an EventSource to listen to the server-sent events 23 | const eventSource = new EventSource(`/download?spotify_link=${encodeURIComponent(spotifyLink)}`); 24 | 25 | eventSource.onmessage = function(event) { 26 | const log = event.data; 27 | 28 | if (log.startsWith("DOWNLOAD:")) { 29 | // Download link received, set progress to 100% 30 | progressBar.value = 100; 31 | 32 | const path = log.split("DOWNLOAD: ")[1].trim(); // ✅ define path BEFORE using it 33 | console.log("Download path from server:", path); // ✅ now it's safe to use 34 | 35 | const downloadLink = document.createElement('a'); 36 | downloadLink.href = `/downloads/${path}`; 37 | downloadLink.download = decodeURIComponent(path.split('/').pop()); 38 | 39 | downloadLink.innerText = "Click to download your file"; 40 | document.getElementById('result').appendChild(downloadLink); 41 | downloadLink.click(); 42 | 43 | // Close the EventSource and hide the progress bar 44 | eventSource.close(); 45 | progressBar.style.display = 'none'; 46 | } else if (log.includes("Download completed") || log.includes("Download process completed successfully")) { 47 | // Show a success message in logs 48 | logsElement.innerHTML += "Download completed successfully.
"; 49 | } else if (log.startsWith("Error")) { 50 | // Display error message and close EventSource 51 | document.getElementById('result').innerText = `Error: ${log}`; 52 | eventSource.close(); 53 | progressBar.style.display = 'none'; 54 | } else { 55 | // Increase progress gradually 56 | progressBar.value = Math.min(progressBar.value + increment, 95); 57 | 58 | // Append log output to logs section 59 | logsElement.innerHTML += log + "
"; 60 | logsElement.scrollTop = logsElement.scrollHeight; 61 | } 62 | }; 63 | 64 | eventSource.onerror = function() { 65 | // Only show error if no success message was received 66 | if (!logsElement.innerHTML.includes("Download completed successfully")) { 67 | document.getElementById('result').innerText = "Error occurred while downloading."; 68 | } 69 | progressBar.style.display = 'none'; 70 | eventSource.close(); 71 | }; 72 | } 73 | // Function to handle the Admin / Log Out button behavior 74 | function handleAdminButton() { 75 | if (document.getElementById('adminButton').innerText === "Admin") { 76 | showLoginModal(); // Show login modal if not logged in 77 | } else { 78 | logout(); // Log out if already logged in 79 | } 80 | } 81 | 82 | // Show login modal 83 | function showLoginModal() { 84 | const loginModal = document.getElementById('loginModal'); 85 | loginModal.classList.add('show'); // Show modal on button click 86 | } 87 | 88 | // Hide login modal 89 | function closeLoginModal() { 90 | const loginModal = document.getElementById('loginModal'); 91 | loginModal.classList.remove('show'); // Hide modal when closed 92 | } 93 | 94 | // Check login status, toggle button text, and show/hide admin message 95 | 96 | async function checkLoginStatus() { 97 | const response = await fetch('/check-login'); 98 | const data = await response.json(); 99 | const adminButton = document.getElementById('adminButton'); 100 | const adminMessage = document.getElementById('adminMessage'); 101 | const adminControls = document.getElementById('adminControls'); 102 | 103 | if (data.loggedIn) { 104 | adminButton.innerText = "Log Out"; 105 | adminMessage.style.display = "block"; 106 | adminControls.style.display = "block"; 107 | } else { 108 | adminButton.innerText = "Admin"; 109 | adminMessage.style.display = "none"; 110 | adminControls.style.display = "none"; 111 | } 112 | } 113 | 114 | 115 | async function logout() { 116 | const response = await fetch('/logout', { method: 'POST' }); 117 | const data = await response.json(); 118 | 119 | if (data.success) { 120 | await checkLoginStatus(); // ✅ Add this line 121 | } 122 | } 123 | 124 | // After successful login, change button text to "Log Out" and show the message 125 | 126 | async function login() { 127 | const username = document.getElementById('username').value; 128 | const password = document.getElementById('password').value; 129 | 130 | const response = await fetch('/login', { 131 | method: 'POST', 132 | headers: { 'Content-Type': 'application/json' }, 133 | body: JSON.stringify({ username, password }) 134 | }); 135 | 136 | const data = await response.json(); 137 | if (data.success) { 138 | document.getElementById('loginMessage').innerText = "Login successful!"; 139 | closeLoginModal(); 140 | await checkLoginStatus(); // ✅ Add this line 141 | } else { 142 | document.getElementById('loginMessage').innerText = "Login failed. Try again."; 143 | } 144 | } 145 | 146 | 147 | async function setDownloadPath() { 148 | const path = document.getElementById('downloadPath').value; 149 | const messageDiv = document.getElementById('pathMessage'); 150 | 151 | if (!path) { 152 | messageDiv.innerText = "Path cannot be empty."; 153 | return; 154 | } 155 | 156 | const response = await fetch('/set-download-path', { 157 | method: 'POST', 158 | headers: {'Content-Type': 'application/json'}, 159 | body: JSON.stringify({path}) 160 | }); 161 | 162 | const data = await response.json(); 163 | 164 | if (data.success) { 165 | messageDiv.innerText = `Download path set successfully to: ${data.new_path}`; 166 | messageDiv.style.color = "lime"; 167 | } else { 168 | messageDiv.innerText = `Error: ${data.message}`; 169 | messageDiv.style.color = "red"; 170 | } 171 | } 172 | 173 | // Call checkLoginStatus on page load to set initial button state and message visibility 174 | window.onload = checkLoginStatus; 175 | 176 | -------------------------------------------------------------------------------- /web/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | font-family: Arial, sans-serif; 6 | } 7 | 8 | /* Dark gray background */ 9 | body { 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | min-height: 100vh; 14 | background-color: #1e1e1e; /* Dark gray background */ 15 | color: #f4f4f9; /* Light text color for contrast */ 16 | } 17 | 18 | .container { 19 | text-align: center; 20 | max-width: 500px; 21 | padding: 20px; 22 | background-color: #2a2a2a; /* Darker gray for container */ 23 | box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2); 24 | border-radius: 8px; 25 | } 26 | 27 | /* Logs window with scroll */ 28 | .logs { 29 | width: 100%; 30 | height: 200px; 31 | background-color: #333; 32 | color: #0f0; 33 | font-family: monospace; 34 | font-size: 14px; 35 | padding: 10px; 36 | overflow-y: auto; 37 | margin-top: 20px; 38 | border-radius: 4px; 39 | border: 1px solid #444; 40 | } 41 | h1 { 42 | margin-bottom: 20px; 43 | } 44 | 45 | input[type="text"] { 46 | width: 80%; 47 | padding: 10px; 48 | border: 1px solid #ccc; 49 | border-radius: 4px; 50 | margin-bottom: 20px; 51 | } 52 | 53 | button { 54 | padding: 10px 20px; 55 | border: none; 56 | background-color: #007bff; 57 | color: #fff; 58 | font-size: 16px; 59 | border-radius: 4px; 60 | cursor: pointer; 61 | } 62 | 63 | button:hover { 64 | background-color: #0056b3; 65 | } 66 | 67 | /* Progress bar styling */ 68 | #progress { 69 | width: 80%; 70 | height: 20px; 71 | margin: 20px auto; /* Center horizontally and add top/bottom margin */ 72 | display: block; /* Ensure it centers in its container */ 73 | appearance: none; 74 | border-radius: 10px; 75 | background-color: #555; 76 | } 77 | 78 | #result { 79 | margin-top: 20px; 80 | font-size: 16px; 81 | color: #fff; 82 | } 83 | 84 | /* Admin button styling */ 85 | #adminButton { 86 | position: absolute; 87 | top: 10px; 88 | right: 10px; 89 | padding: 8px 16px; 90 | background-color: #007bff; 91 | color: #fff; 92 | border: none; 93 | border-radius: 4px; 94 | cursor: pointer; 95 | } 96 | 97 | /* Modal styling */ 98 | .modal { 99 | visibility: hidden; /* Start hidden */ 100 | position: fixed; 101 | z-index: 1; 102 | left: 0; 103 | top: 0; 104 | width: 100%; 105 | height: 100%; 106 | background-color: rgba(0, 0, 0, 0.5); /* Dark overlay */ 107 | display: flex; 108 | justify-content: center; 109 | align-items: center; 110 | } 111 | 112 | .modal.show { 113 | visibility: visible; /* Make modal visible */ 114 | } 115 | 116 | .modal-content { 117 | background-color: #2a2a2a; /* Darker background for dark mode */ 118 | color: #f4f4f9; /* Light text color */ 119 | padding: 20px; 120 | border-radius: 8px; 121 | text-align: center; 122 | width: 80%; 123 | max-width: 400px; 124 | } 125 | 126 | input[type="text"], input[type="password"] { 127 | width: 90%; 128 | padding: 10px; 129 | border: 1px solid #555; 130 | border-radius: 4px; 131 | margin-bottom: 20px; 132 | background-color: #333; /* Dark input background */ 133 | color: #f4f4f9; 134 | } 135 | 136 | button { 137 | background-color: #007bff; 138 | color: #fff; 139 | border: none; 140 | padding: 10px 20px; 141 | font-size: 16px; 142 | cursor: pointer; 143 | border-radius: 4px; 144 | } 145 | 146 | button:hover { 147 | background-color: #0056b3; 148 | } 149 | 150 | .close { 151 | position: absolute; 152 | top: 10px; 153 | right: 10px; 154 | font-size: 20px; 155 | cursor: pointer; 156 | } 157 | 158 | --------------------------------------------------------------------------------