Compare commits

..

2 Commits

Author SHA1 Message Date
MrUnknownDE de82812e30 add seo optimization 2026-05-05 20:30:27 +02:00
MrUnknownDE 353f9b9e9b remove server-sid history 2026-05-05 20:27:42 +02:00
5 changed files with 1310 additions and 626 deletions
+6 -3
View File
@@ -25,9 +25,12 @@ S3_ENDPOINT_URL="https://dein-s3-endpoint.example.com"
# Wasabi: https://s3.<region>.wasabisys.com/<bucket-name>/ # Wasabi: https://s3.<region>.wasabisys.com/<bucket-name>/
S3_PUBLIC_URL_BASE="https://dein-bucket-public-url.example.com/" S3_PUBLIC_URL_BASE="https://dein-bucket-public-url.example.com/"
# Verlauf aktivieren/deaktivieren (true/false) # SEO / Site metadata
# Wenn deaktiviert, wird kein Verlauf angezeigt, gespeichert oder geladen. # Öffentliche URL der Seite (ohne Trailing-Slash). Wird für Canonical, Sitemap und OG-Tags genutzt.
ENABLE_HISTORY="true" # Leer lassen wenn noch keine Domain konfiguriert ist.
SITE_URL="https://dl.example.com"
SITE_NAME="unknownMedien.dl"
# SITE_DESCRIPTION="..." # Optional: überschreibt den Standard-Beschreibungstext
# Anzahl der Worker-Threads für gleichzeitige Downloads/Uploads. # Anzahl der Worker-Threads für gleichzeitige Downloads/Uploads.
# WICHTIG: Die Statusanzeige im Frontend funktioniert nur korrekt mit MAX_WORKERS=1. # WICHTIG: Die Statusanzeige im Frontend funktioniert nur korrekt mit MAX_WORKERS=1.
+46 -66
View File
@@ -13,7 +13,7 @@ from datetime import datetime
import random import random
import string import string
import re import re
from flask import Flask, render_template, request, jsonify, Response, copy_current_request_context from flask import Flask, render_template, request, jsonify, Response, copy_current_request_context, make_response
import time import time
# import queue # ALT # import queue # ALT
import multiprocessing # NEU: Für prozessübergreifende Queue import multiprocessing # NEU: Für prozessübergreifende Queue
@@ -23,8 +23,13 @@ import subprocess # NEU: Für FFmpeg Aufruf
import traceback # NEU: Für detaillierte Fehlermeldungen import traceback # NEU: Für detaillierte Fehlermeldungen
# --- Konstanten --- # --- Konstanten ---
HISTORY_FILE = "download_history.json"
STATS_FILE = "stats.json" STATS_FILE = "stats.json"
# --- Site-Metadaten (für SSR / SEO) ---
SITE_NAME = os.getenv('SITE_NAME', 'unknownMedien.dl')
SITE_URL = os.getenv('SITE_URL', '').rstrip('/')
SITE_DESCRIPTION = os.getenv('SITE_DESCRIPTION', 'Free media downloader. Save audio and video from YouTube, SoundCloud, TikTok, Instagram and Twitter directly to your cloud storage.')
SITE_KEYWORDS = 'media downloader, youtube downloader, soundcloud downloader, tiktok downloader, instagram reels, mp3 download, mp4 download, s3 upload'
RANDOM_NAME_LENGTH = 4 RANDOM_NAME_LENGTH = 4
MAX_FILENAME_RETRIES = 10 MAX_FILENAME_RETRIES = 10
ANSI_ESCAPE_REGEX = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') ANSI_ESCAPE_REGEX = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
@@ -72,7 +77,6 @@ if env_loaded_successfully: logging.info(f".env Datei gefunden und geladen von:
else: logging.warning(".env Datei nicht gefunden...") else: logging.warning(".env Datei nicht gefunden...")
# --- Konfiguration aus .env lesen --- # --- Konfiguration aus .env lesen ---
ENABLE_HISTORY = os.getenv('ENABLE_HISTORY', 'true').lower() == 'true'
try: try:
MAX_WORKERS = int(os.getenv('MAX_WORKERS', '1')) MAX_WORKERS = int(os.getenv('MAX_WORKERS', '1'))
if MAX_WORKERS < 1: if MAX_WORKERS < 1:
@@ -84,7 +88,6 @@ except ValueError:
if MAX_WORKERS <= 0: MAX_WORKERS = 1 # Sicherstellen, dass mindestens 1 Worker läuft if MAX_WORKERS <= 0: MAX_WORKERS = 1 # Sicherstellen, dass mindestens 1 Worker läuft
logging.info(f"Verlauf aktiviert: {ENABLE_HISTORY}")
logging.info(f"Maximale Worker-Threads (für Hintergrundverarbeitung): {MAX_WORKERS}") logging.info(f"Maximale Worker-Threads (für Hintergrundverarbeitung): {MAX_WORKERS}")
# --- Flask App Initialisierung --- # --- Flask App Initialisierung ---
@@ -455,42 +458,7 @@ def upload_to_s3(job_id, file_path, object_name, file_extension, bucket_name, aw
update_status(job_id, error=error_msg, running=False) update_status(job_id, error=error_msg, running=False)
return False return False
# --- History Funktionen (Backend - unverändert) --- # --- Statistik Funktionen ---
def load_history():
if not ENABLE_HISTORY: return []
try:
if os.path.exists(HISTORY_FILE):
if os.path.getsize(HISTORY_FILE) == 0: logging.warning(f"{HISTORY_FILE} ist leer."); return []
with open(HISTORY_FILE, 'r', encoding='utf-8') as f: history = json.load(f)
return history if isinstance(history, list) else []
else: return []
except json.JSONDecodeError as e: logging.error(f"Fehler Laden History (JSON ungültig): {e}."); return []
except Exception as e: logging.error(f"Fehler Laden History (Allgemein): {e}"); return []
def save_history(history_data):
if not ENABLE_HISTORY: return True
try:
with open(HISTORY_FILE, 'w', encoding='utf-8') as f: json.dump(history_data, f, indent=4, ensure_ascii=False)
logging.info(f"History gespeichert: {HISTORY_FILE}")
return True
except Exception as e: logging.error(f"Fehler Speichern History: {e}"); return False
def add_history_entry(platform, title, source_url, s3_url):
if not ENABLE_HISTORY: return True
history = load_history()
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
entry = {"timestamp": timestamp, "platform": platform, "title": title, "source_url": source_url, "s3_url": s3_url}
history.insert(0, entry)
return save_history(history)
def clear_history_file():
if not ENABLE_HISTORY: return True
try:
if os.path.exists(HISTORY_FILE): os.remove(HISTORY_FILE); logging.info("History-Datei gelöscht.")
return True
except Exception as e: logging.error(f"Fehler Löschen History-Datei: {e}"); return False
# --- Statistik Funktionen (Backend - unverändert) ---
def load_stats(): def load_stats():
default_stats = {'total_jobs': 0, 'successful_jobs': 0, 'total_duration_seconds': 0.0, 'total_size_bytes': 0} default_stats = {'total_jobs': 0, 'successful_jobs': 0, 'total_duration_seconds': 0.0, 'total_size_bytes': 0}
try: try:
@@ -633,10 +601,6 @@ def run_download_upload_task(job_id, url, platform, format_preference, mp3_bitra
update_status(job_id, message="Abgeschlossen! (Keine Public URL Base konfiguriert)") update_status(job_id, message="Abgeschlossen! (Keine Public URL Base konfiguriert)")
logging.warning(f"[{job_id}] Öffentliche URL kann nicht angezeigt werden (S3_PUBLIC_URL_BASE fehlt).") logging.warning(f"[{job_id}] Öffentliche URL kann nicht angezeigt werden (S3_PUBLIC_URL_BASE fehlt).")
if not add_history_entry(platform, track_title, url, final_s3_url_for_history):
logging.warning(f"[{job_id}] Konnte Eintrag nicht zur History hinzufügen.")
update_status(job_id, log_entry="WARNUNG: Konnte Eintrag nicht zur History hinzufügen.")
except Exception as e: except Exception as e:
logging.exception(f"[{job_id}] Unerwarteter Fehler im Hauptverarbeitungsblock für URL {url}:") logging.exception(f"[{job_id}] Unerwarteter Fehler im Hauptverarbeitungsblock für URL {url}:")
final_error_message = f"Unerwarteter Verarbeitungsfehler: {strip_ansi_codes(str(e))}" final_error_message = f"Unerwarteter Verarbeitungsfehler: {strip_ansi_codes(str(e))}"
@@ -757,7 +721,43 @@ def index():
if not isinstance(job_statuses, type(manager.dict())): if not isinstance(job_statuses, type(manager.dict())):
job_statuses = manager.dict() job_statuses = manager.dict()
logging.warning("job_statuses wurde neu initialisiert (wahrscheinlich nach Reload).") logging.warning("job_statuses wurde neu initialisiert (wahrscheinlich nach Reload).")
return render_template('index.html', history_enabled=ENABLE_HISTORY) stats = load_stats()
ctx = {
'site_name': SITE_NAME,
'site_url': SITE_URL,
'site_description': SITE_DESCRIPTION,
'site_keywords': SITE_KEYWORDS,
'platforms': SUPPORTED_PLATFORMS,
'total_jobs': stats.get('total_jobs', 0),
'successful_jobs': stats.get('successful_jobs', 0),
}
return render_template('index.html', **ctx)
@app.route('/robots.txt')
def robots_txt():
lines = [
'User-agent: *',
'Allow: /',
f'Sitemap: {SITE_URL}/sitemap.xml' if SITE_URL else '',
]
resp = make_response('\n'.join(filter(None, lines)), 200)
resp.headers['Content-Type'] = 'text/plain'
return resp
@app.route('/sitemap.xml')
def sitemap_xml():
url_root = SITE_URL or request.url_root.rstrip('/')
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>{url_root}/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>'''
resp = make_response(xml, 200)
resp.headers['Content-Type'] = 'application/xml'
return resp
@app.route('/start_download', methods=['POST']) @app.route('/start_download', methods=['POST'])
def start_download(): def start_download():
@@ -787,14 +787,6 @@ def start_download():
if platform == "Twitter": error_msg += " Stelle sicher, dass es ein Tweet-Link ist (enthält /status/)." if platform == "Twitter": error_msg += " Stelle sicher, dass es ein Tweet-Link ist (enthält /status/)."
return jsonify({"error": error_msg}), 400 return jsonify({"error": error_msg}), 400
if ENABLE_HISTORY:
history = load_history()
for entry in history:
entry_url = entry.get('source_url') or entry.get('soundcloud_url')
if entry_url == url:
entry_platform = entry.get('platform', 'Unbekannt')
return jsonify({"error": f"Dieser Link ({entry_platform}) wurde bereits verarbeitet (Verlauf aktiv)."}), 400
access_key = os.getenv('AWS_ACCESS_KEY_ID'); secret_key = os.getenv('AWS_SECRET_ACCESS_KEY') access_key = os.getenv('AWS_ACCESS_KEY_ID'); secret_key = os.getenv('AWS_SECRET_ACCESS_KEY')
bucket_name = os.getenv('AWS_S3_BUCKET_NAME'); region_name = os.getenv('AWS_REGION') bucket_name = os.getenv('AWS_S3_BUCKET_NAME'); region_name = os.getenv('AWS_REGION')
endpoint_url = os.getenv('S3_ENDPOINT_URL') endpoint_url = os.getenv('S3_ENDPOINT_URL')
@@ -845,18 +837,6 @@ def get_status():
current_status_copy["logs"] = list(current_status_copy["logs"]) current_status_copy["logs"] = list(current_status_copy["logs"])
return jsonify(current_status_copy) return jsonify(current_status_copy)
@app.route('/history')
def get_history():
history = load_history()
return jsonify(history)
@app.route('/clear_history', methods=['POST'])
def clear_history_route():
if clear_history_file():
return jsonify({"message": "Verlauf gelöscht (falls aktiviert)."}), 200
else:
return jsonify({"error": "Fehler beim Löschen des Verlaufs."}), 500
@app.route('/stats') @app.route('/stats')
def get_stats(): def get_stats():
stats_data = load_stats() stats_data = load_stats()
@@ -942,7 +922,7 @@ if __name__ == '__main__':
else: print("\nINFO: FFmpeg gefunden.\n") else: print("\nINFO: FFmpeg gefunden.\n")
print(f"\nFlask App startet (lokaler Modus)..."); print(f"\nFlask App startet (lokaler Modus)...");
print(f"Download-Verzeichnis: {DOWNLOAD_DIR}") print(f"Download-Verzeichnis: {DOWNLOAD_DIR}")
print(f"Verlauf aktiviert: {ENABLE_HISTORY}") print(f"History: Browser localStorage only")
print(f"Öffne http://127.0.0.1:5000 oder http://<Deine-IP>:5000 im Browser.") print(f"Öffne http://127.0.0.1:5000 oder http://<Deine-IP>:5000 im Browser.")
print("(Beende mit STRG+C)\n") print("(Beende mit STRG+C)\n")
app.run(debug=False, host='0.0.0.0', port=5000, use_reloader=False) app.run(debug=False, host='0.0.0.0', port=5000, use_reloader=False)
+240 -186
View File
@@ -1,19 +1,15 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const dom = { const dom = {
// Form & Main
form: document.getElementById('upload-form'), form: document.getElementById('upload-form'),
urlInput: document.getElementById('url'), urlInput: document.getElementById('url'),
submitBtn: document.getElementById('submit-button'),
platformInput: document.getElementById('input-platform'), platformInput: document.getElementById('input-platform'),
// Detection UI
detectionArea: document.getElementById('detection-area'), detectionArea: document.getElementById('detection-area'),
detectedText: document.getElementById('detected-text'), detectedText: document.getElementById('detected-text'),
detectedIcon: document.getElementById('detected-icon'), detectedIcon: document.getElementById('detected-icon'),
platformPulse: document.getElementById('platform-pulse'),
platformBadge: document.getElementById('platform-badge'),
urlIcon: document.getElementById('url-icon'), urlIcon: document.getElementById('url-icon'),
inputWrapper: document.getElementById('input-wrapper'),
// Options
optionsContainer: document.getElementById('options-container'),
ytOptions: document.getElementById('youtube-options'), ytOptions: document.getElementById('youtube-options'),
codecSection: document.getElementById('codec-options-section'), codecSection: document.getElementById('codec-options-section'),
ytRadios: document.querySelectorAll('input[name="yt_format"]'), ytRadios: document.querySelectorAll('input[name="yt_format"]'),
@@ -21,283 +17,341 @@ document.addEventListener('DOMContentLoaded', () => {
mp4Select: document.getElementById('mp4_quality'), mp4Select: document.getElementById('mp4_quality'),
codecSwitch: document.getElementById('codec-switch'), codecSwitch: document.getElementById('codec-switch'),
codecPref: document.getElementById('codec_preference'), codecPref: document.getElementById('codec_preference'),
// Views
processView: document.getElementById('process-view'), processView: document.getElementById('process-view'),
resultView: document.getElementById('result-view'), resultView: document.getElementById('result-view'),
formContainer: document.querySelector('.form-container'), formContainer: document.querySelector('.form-container'),
// Progress & Logs
statusMsg: document.getElementById('status-message'), statusMsg: document.getElementById('status-message'),
progressBar: document.getElementById('progress-bar'), progressBar: document.getElementById('progress-bar'),
progressPct: document.getElementById('progress-pct'),
logContent: document.getElementById('pseudo-log-content'), logContent: document.getElementById('pseudo-log-content'),
// Result
resultUrl: document.getElementById('result-url'), resultUrl: document.getElementById('result-url'),
errorMsg: document.getElementById('error-message'), errorMsg: document.getElementById('error-message'),
historyTbody: document.querySelector('#history-table tbody'),
// History clearHistoryBtn:document.getElementById('clear-history-button'),
historyTableBody: document.querySelector('#history-table tbody'),
clearHistoryBtn: document.getElementById('clear-history-button')
}; };
let currentJobId = null; let jobId = null;
let pollingInterval = null; let pollTimer = null;
let pseudoLogInterval = null; let pseudoTimer = null;
let lastLogIndex = 0;
let realLogsActive = false;
const platforms = [ // ---- Platform definitions ----
{ name: 'SoundCloud', pattern: /soundcloud\.com/, icon: 'fa-soundcloud', color: '#ff5500' }, const PLATFORMS = [
{ name: 'YouTube', pattern: /(youtube\.com|youtu\.be)/, icon: 'fa-youtube', color: '#ff0000' }, { name:'SoundCloud', pattern:/soundcloud\.com/, icon:'fa-soundcloud', color:'#ff5500', rgb:'255,85,0' },
{ name: 'TikTok', pattern: /tiktok\.com/, icon: 'fa-tiktok', color: '#fe2c55' }, { name:'YouTube', pattern:/(youtube\.com|youtu\.be)/,icon:'fa-youtube', color:'#ff0000', rgb:'255,0,0' },
{ name: 'Instagram', pattern: /instagram\.com/, icon: 'fa-instagram', color: '#E1306C' }, { name:'TikTok', pattern:/tiktok\.com/, icon:'fa-tiktok', color:'#fe2c55', rgb:'254,44,85' },
{ name: 'Twitter', pattern: /(twitter\.com|x\.com)/, icon: 'fa-x-twitter', color: '#fff' } { name:'Instagram', pattern:/instagram\.com/, icon:'fa-instagram', color:'#E1306C', rgb:'225,48,108' },
{ name:'Twitter', pattern:/(twitter\.com|x\.com)/, icon:'fa-x-twitter', color:'#1d9bf0', rgb:'29,155,240' },
]; ];
const pseudoLogs = [ const PSEUDO_LOGS = [
"Connecting to media node...", 'Connecting to media node...',
"Handshaking with API...", 'Handshaking with API endpoint...',
"Resolving stream URL...", 'Resolving stream metadata...',
"Allocating buffer...", 'Allocating download buffer...',
"Starting download stream...", 'Starting download stream...',
"Processing data chunks...", 'Processing data chunks...',
"Verifying integrity...", 'Verifying file integrity...',
"Optimizing container...", 'Optimizing audio/video container...',
"Finalizing upload..." 'Preparing upload to storage...',
'Syncing with remote bucket...',
]; ];
// ---- Init ----
function init() { function init() {
setupEvents(); bindEvents();
loadHistory(); renderHistory();
} }
function setupEvents() { function bindEvents() {
dom.urlInput.addEventListener('input', handleUrlInput); dom.urlInput.addEventListener('input', onUrlChange);
dom.form.addEventListener('submit', handleSubmit); dom.form.addEventListener('submit', onSubmit);
dom.ytRadios.forEach(r => r.addEventListener('change', updateQualitySelect));
dom.ytRadios.forEach(r => r.addEventListener('change', updateYtOptions));
if (dom.codecSwitch) { if (dom.codecSwitch) {
dom.codecSwitch.addEventListener('change', e => { dom.codecSwitch.addEventListener('change', e => {
dom.codecPref.value = e.target.checked ? 'h264' : 'original'; dom.codecPref.value = e.target.checked ? 'h264' : 'original';
}); });
} }
if (dom.clearHistoryBtn) {
if(dom.clearHistoryBtn) dom.clearHistoryBtn.addEventListener('click', clearHistory); dom.clearHistoryBtn.addEventListener('click', clearHistory);
}
} }
// --- Detection --- // ---- URL detection ----
function handleUrlInput() { function onUrlChange() {
const url = dom.urlInput.value.trim(); const val = dom.urlInput.value.trim();
const detected = platforms.find(p => p.pattern.test(url)); const match = PLATFORMS.find(p => p.pattern.test(val));
if (detected) { if (match) {
dom.platformInput.value = detected.name; applyPlatform(match);
dom.detectedText.textContent = detected.name + " detected"; } else if (!val) {
dom.detectedIcon.className = `fab ${detected.icon}`; resetPlatform();
dom.detectionArea.style.opacity = '1'; }
dom.detectionArea.style.transform = 'translateY(0)'; }
// Icon in Input function applyPlatform(p) {
dom.urlIcon.className = `fab ${detected.icon}`; dom.platformInput.value = p.name;
dom.urlIcon.style.color = detected.color; dom.detectedText.textContent = p.name + ' detected';
dom.detectedIcon.className = 'fab ' + p.icon;
dom.urlIcon.innerHTML = `<i class="fab ${p.icon}"></i>`;
dom.urlIcon.style.color = p.color;
showOptions(detected.name); // CSS vars for color theming
} else { document.documentElement.style.setProperty('--platform-color', p.color);
if(url.length === 0) { document.documentElement.style.setProperty('--platform-rgb', p.rgb);
dom.detectionArea.style.opacity = '0';
dom.detectionArea.style.transform = 'translateY(-10px)'; // Badge colors
dom.urlIcon.className = 'fas fa-link'; dom.platformBadge.style.borderColor = `rgba(${p.rgb}, 0.3)`;
dom.platformBadge.style.background = `rgba(${p.rgb}, 0.1)`;
dom.platformBadge.style.color = p.color;
if (dom.platformPulse) {
dom.platformPulse.style.background = p.color;
dom.platformPulse.style.boxShadow = `0 0 8px ${p.color}`;
}
// Input glow tint
dom.inputWrapper.style.setProperty('--platform-glow', `rgba(${p.rgb}, 0.15)`);
dom.detectionArea.classList.add('visible');
showOptions(p.name);
}
function resetPlatform() {
document.documentElement.style.setProperty('--platform-color', '#8b5cf6');
dom.urlIcon.innerHTML = '<i class="fas fa-link"></i>';
dom.urlIcon.style.color = ''; dom.urlIcon.style.color = '';
} dom.detectionArea.classList.remove('visible');
// Keep options hidden if no match
}
} }
function showOptions(platform) { function showOptions(name) {
dom.ytOptions.classList.add('d-none'); dom.ytOptions.classList.add('d-none');
dom.codecSection.classList.add('d-none'); dom.codecSection.classList.add('d-none');
if (name === 'YouTube') {
if (platform === 'YouTube') {
dom.ytOptions.classList.remove('d-none'); dom.ytOptions.classList.remove('d-none');
updateYtOptions(); updateQualitySelect();
} else if (['TikTok', 'Instagram', 'Twitter'].includes(platform)) { } else if (['TikTok','Instagram','Twitter'].includes(name)) {
dom.codecSection.classList.remove('d-none'); dom.codecSection.classList.remove('d-none');
} }
} }
function updateYtOptions() { function updateQualitySelect() {
const format = document.querySelector('input[name="yt_format"]:checked').value; const fmt = document.querySelector('input[name="yt_format"]:checked')?.value;
if (format === 'mp3') { dom.mp3Select.classList.toggle('d-none', fmt !== 'mp3');
dom.mp3Select.classList.remove('d-none'); dom.mp4Select.classList.toggle('d-none', fmt === 'mp3');
dom.mp4Select.classList.add('d-none');
} else {
dom.mp3Select.classList.add('d-none');
dom.mp4Select.classList.remove('d-none');
}
} }
// --- Processing --- // ---- Submit ----
async function handleSubmit(e) { async function onSubmit(e) {
e.preventDefault(); e.preventDefault();
// UI Switch
dom.formContainer.classList.add('d-none'); dom.formContainer.classList.add('d-none');
dom.processView.classList.remove('d-none'); dom.processView.classList.remove('d-none');
dom.resultView.classList.add('d-none'); dom.resultView.classList.add('d-none');
dom.errorMsg.classList.add('d-none'); dom.errorMsg.classList.add('d-none');
dom.logContent.innerHTML = '';
lastLogIndex = 0;
realLogsActive = false;
setProgress(0);
dom.statusMsg.textContent = 'INITIALIZING...';
startPseudoLogs(); startPseudoLogs();
const formData = new FormData(dom.form);
try { try {
const res = await fetch('/start_download', { method: 'POST', body: formData }); const res = await fetch('/start_download', { method:'POST', body: new FormData(dom.form) });
const data = await res.json(); const data = await res.json();
if (res.ok && data.job_id) { if (res.ok && data.job_id) {
currentJobId = data.job_id; jobId = data.job_id;
startPolling(); startPolling();
} else { } else {
throw new Error(data.error || "Start failed"); throw new Error(data.error || 'Failed to start job');
} }
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
} }
// ---- Polling ----
function startPolling() { function startPolling() {
pollingInterval = setInterval(async () => { pollTimer = setInterval(async () => {
if (!jobId) return;
try { try {
if(!currentJobId) return; const res = await fetch(`/status?job_id=${jobId}`);
const res = await fetch(`/status?job_id=${currentJobId}`); if (res.status === 404) { showError('Job not found'); return; }
if (res.status === 404) { showError("Job not found"); return; } const s = await res.json();
const status = await res.json(); setProgress(s.progress || 0);
if (s.message) dom.statusMsg.textContent = s.message.toUpperCase();
// Update UI // Show real server logs as they arrive
dom.progressBar.style.width = (status.progress || 0) + "%"; if (Array.isArray(s.logs) && s.logs.length > lastLogIndex) {
if(status.message) dom.statusMsg.textContent = status.message.toUpperCase(); if (!realLogsActive) {
realLogsActive = true;
if (status.status === 'completed') { clearInterval(pseudoTimer);
finishJob(status.result_url); removeCursor();
} else if (status.status === 'error' || status.error) {
showError(status.error || status.message);
} }
} catch (e) { s.logs.slice(lastLogIndex).forEach(line => addLine(line, 'is-real'));
console.error(e); lastLogIndex = s.logs.length;
}
if (s.status === 'completed') {
onJobDone(s.result_url);
} else if (s.status === 'error' || s.error) {
showError(s.error || s.message);
}
} catch {
// network blip — keep polling
} }
}, 1000); }, 1000);
} }
function finishJob(url) { // ---- Terminal ----
clearInterval(pollingInterval); function addLine(text, cls = '') {
clearInterval(pseudoLogInterval); removeCursor();
dom.processView.classList.add('d-none'); const el = document.createElement('div');
dom.resultView.classList.remove('d-none'); el.className = 'terminal-line' + (cls ? ' ' + cls : '');
dom.resultUrl.href = url; el.textContent = (cls === 'is-real' ? '» ' : '> ') + text;
saveHistory(url); const cur = document.createElement('span');
cur.className = 'term-cursor';
el.appendChild(cur);
dom.logContent.appendChild(el);
dom.logContent.scrollTop = dom.logContent.scrollHeight;
} }
function showError(msg) { function removeCursor() {
clearInterval(pollingInterval); dom.logContent.querySelectorAll('.term-cursor').forEach(c => c.remove());
clearInterval(pseudoLogInterval); }
function startPseudoLogs() {
let idx = 0;
addLine(PSEUDO_LOGS[idx++]);
pseudoTimer = setInterval(() => {
if (realLogsActive) { clearInterval(pseudoTimer); return; }
if (idx < PSEUDO_LOGS.length) addLine(PSEUDO_LOGS[idx++]);
}, 2400);
}
// ---- Progress ----
function setProgress(val) {
const pct = Math.min(100, Math.max(0, Math.round(val)));
dom.progressBar.style.width = pct + '%';
if (dom.progressPct) dom.progressPct.textContent = pct + '%';
}
// ---- Finish ----
function onJobDone(url) {
clearInterval(pollTimer);
clearInterval(pseudoTimer);
removeCursor();
const done = document.createElement('div');
done.className = 'terminal-line is-success';
done.textContent = '✓ Upload complete. Your file is ready.';
dom.logContent.appendChild(done);
dom.logContent.scrollTop = dom.logContent.scrollHeight;
setProgress(100);
setTimeout(() => {
dom.processView.classList.add('d-none'); dom.processView.classList.add('d-none');
dom.formContainer.classList.remove('d-none'); // Back to form dom.resultView.classList.remove('d-none');
if (url) dom.resultUrl.href = url;
}, 700);
persistHistory(url);
}
// ---- Error ----
function showError(msg) {
clearInterval(pollTimer);
clearInterval(pseudoTimer);
dom.processView.classList.add('d-none');
dom.formContainer.classList.remove('d-none');
dom.errorMsg.textContent = msg; dom.errorMsg.textContent = msg;
dom.errorMsg.classList.remove('d-none'); dom.errorMsg.classList.remove('d-none');
} }
// --- Pseudo Logs (CLI Effect) --- // ---- LocalStorage History ----
function startPseudoLogs() { const LS_KEY = 'mdl_history';
dom.logContent.innerHTML = ''; const TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
let index = 0;
function addLine() { function persistHistory(resultUrl) {
if (index >= pseudoLogs.length) index = 0; // Loop or stop const entry = {
const div = document.createElement('div'); url: resultUrl,
div.className = 'terminal-line'; source: dom.urlInput.value.trim(),
div.textContent = "> " + pseudoLogs[index]; platform: dom.platformInput.value,
dom.logContent.appendChild(div); title: dom.platformInput.value + ' Download',
ts: Date.now(),
// Auto scroll };
const win = document.querySelector('.terminal-window .terminal-body'); let data = readHistory();
win.scrollTop = win.scrollHeight; data.unshift(entry);
if (data.length > 30) data.length = 30;
index++; localStorage.setItem(LS_KEY, JSON.stringify(data));
renderHistory();
} }
addLine(); // First one immediate function readHistory() {
pseudoLogInterval = setInterval(addLine, 2000); try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return [];
const data = JSON.parse(raw).filter(i => Date.now() - i.ts < TTL);
localStorage.setItem(LS_KEY, JSON.stringify(data));
return data;
} catch { return []; }
} }
// --- Local History --- function renderHistory() {
function loadHistory() { const data = readHistory();
const raw = localStorage.getItem('mdl_history'); if (!dom.historyTbody) return;
if(!raw) {
dom.historyTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-white-50 py-3">No history available</td></tr>'; if (!data.length) {
dom.historyTbody.innerHTML =
'<tr><td colspan="5" style="text-align:center;padding:2rem;color:rgba(255,255,255,0.25);font-size:0.82rem">No downloads yet</td></tr>';
return; return;
} }
let data = JSON.parse(raw); dom.historyTbody.innerHTML = '';
// Clean expired (>7 days) data.forEach((item, i) => {
const now = Date.now(); const d = new Date(item.ts);
data = data.filter(i => (now - i.ts) < (7 * 24 * 60 * 60 * 1000)); const date = d.toLocaleDateString([], { month:'short', day:'numeric' });
localStorage.setItem('mdl_history', JSON.stringify(data)); const time = d.toLocaleTimeString([], { hour:'2-digit', minute:'2-digit' });
renderHistory(data);
}
function renderHistory(data) {
dom.historyTableBody.innerHTML = '';
data.forEach(item => {
const date = new Date(item.ts);
const time = date.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.className = 'history-row-enter';
tr.style.animationDelay = (i * 0.04) + 's';
tr.innerHTML = ` tr.innerHTML = `
<td class="ps-4 text-white-50 small font-monospace">${time}</td> <td class="ps-4" style="font-family:var(--mono);font-size:0.72rem;color:rgba(255,255,255,0.35);white-space:nowrap">${date} ${time}</td>
<td class="text-center text-primary-light small">${item.platform}</td> <td><span style="background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.2);color:var(--accent);padding:2px 10px;border-radius:100px;font-size:0.7rem">${escHtml(item.platform)}</span></td>
<td class="text-truncate" style="max-width: 150px;">${item.title || 'Unknown'}</td> <td style="max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(item.title || 'Download')}</td>
<td class="text-center"> <td style="text-align:center">
<a href="${item.source}" target="_blank" class="text-white-50" title="Open source: ${item.source}"> ${item.source
<i class="fas fa-link"></i> ? `<a href="${escHtml(item.source)}" target="_blank" rel="noopener" title="${escHtml(item.source)}"><i class="fas fa-arrow-up-right-from-square fa-sm"></i></a>`
</a> : '<span style="color:rgba(255,255,255,0.2)">—</span>'}
</td> </td>
<td class="text-end pe-4"> <td class="text-end pe-4">
<a href="${item.url}" target="_blank" class="text-primary-light" title="Download"> <a href="${escHtml(item.url)}" target="_blank" rel="noopener"
<i class="fas fa-download"></i> style="background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.2);color:var(--accent);padding:3px 12px;border-radius:100px;font-size:0.72rem;text-decoration:none;white-space:nowrap">
<i class="fas fa-download me-1"></i>Get
</a> </a>
</td> </td>`;
`; dom.historyTbody.appendChild(tr);
dom.historyTableBody.appendChild(tr);
}); });
} }
function saveHistory(resultUrl) {
const pf = dom.platformInput.value;
const sourceUrl = dom.urlInput.value; // Capture source URL
const entry = {
url: resultUrl,
source: sourceUrl,
platform: pf,
title: pf + " Download",
ts: Date.now()
};
let data = JSON.parse(localStorage.getItem('mdl_history') || '[]');
data.unshift(entry);
if(data.length > 20) data.pop(); // Max 20 entries
localStorage.setItem('mdl_history', JSON.stringify(data));
loadHistory();
}
function clearHistory() { function clearHistory() {
if(confirm("Really clear history?")) { if (!confirm('Clear all download history?')) return;
localStorage.removeItem('mdl_history'); localStorage.removeItem(LS_KEY);
loadHistory(); renderHistory();
} }
function escHtml(s) {
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
init(); init();
+743 -194
View File
File diff suppressed because it is too large Load Diff
+229 -131
View File
@@ -1,202 +1,300 @@
<!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>unknownMedien.dl</title>
<!-- Bootstrap CSS --> <!-- Primary SEO -->
<title>{{ site_name }} — Media Downloader</title>
<meta name="description" content="{{ site_description }}">
<meta name="keywords" content="{{ site_keywords }}">
<meta name="author" content="{{ site_name }}">
<meta name="robots" content="index, follow">
{% if site_url %}
<link rel="canonical" href="{{ site_url }}/">
{% endif %}
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="{{ site_name }} — Media Downloader">
<meta property="og:description" content="{{ site_description }}">
<meta property="og:site_name" content="{{ site_name }}">
{% if site_url %}
<meta property="og:url" content="{{ site_url }}/">
{% endif %}
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{ site_name }} — Media Downloader">
<meta name="twitter:description" content="{{ site_description }}">
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "{{ site_name }}",
"description": "{{ site_description }}",
"applicationCategory": "MultimediaApplication",
"operatingSystem": "Web",
{% if site_url %}"url": "{{ site_url }}/",{% endif %}
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
"featureList": [
{% for p in platforms %}"Download from {{ p }}"{% if not loop.last %},{% endif %}{% endfor %}
]
}
</script>
<!-- Styles -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome --> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" /> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;700&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <body>
<!-- Animated Background --> <!-- Background layers -->
<div class="bg-gradient-animate"></div> <div class="bg-base" aria-hidden="true"></div>
<div class="bg-glow bg-glow-1"></div> <div class="bg-mesh" aria-hidden="true"></div>
<div class="bg-glow bg-glow-2"></div> <div class="bg-glow bg-glow-1" aria-hidden="true"></div>
<div class="bg-glow bg-glow-2" aria-hidden="true"></div>
<!-- Header / Nav --> <div class="bg-glow bg-glow-3" aria-hidden="true"></div>
<nav class="navbar fixed-top w-100 p-4 d-flex justify-content-between align-items-center z-3"> <div class="bg-noise" aria-hidden="true"></div>
<div class="brand fw-bold text-white fs-4 tracking-wide"> <div class="particles" aria-hidden="true">
unknownMedien<span class="text-primary-light">.dl</span> <span class="particle p1"></span>
<span class="particle p2"></span>
<span class="particle p3"></span>
<span class="particle p4"></span>
<span class="particle p5"></span>
<span class="particle p6"></span>
</div> </div>
<button class="btn btn-glass-icon" data-bs-toggle="modal" data-bs-target="#historyModal" title="History">
<i class="fas fa-history"></i> <!-- Header -->
<header>
<nav class="navbar fixed-top w-100 px-4 py-3 d-flex justify-content-between align-items-center"
role="navigation" aria-label="Main navigation">
<a href="/" class="brand text-decoration-none">
unknown<span class="brand-accent">Medien</span><span class="brand-dot">.dl</span>
</a>
<button class="btn btn-glass-icon" data-bs-toggle="modal" data-bs-target="#historyModal"
title="View download history" aria-label="Open history">
<i class="fas fa-clock-rotate-left" aria-hidden="true"></i>
</button> </button>
</nav> </nav>
</header>
<!-- Main Content (Hero) --> <!-- Main Content -->
<main class="container-fluid min-vh-100 d-flex flex-column justify-content-center align-items-center position-relative z-2"> <main class="page-center" id="main-content">
<div class="hero-wrapper">
<div class="hero-wrapper w-100 text-center"> <!-- Eyebrow -->
<p class="eyebrow fade-in" aria-hidden="true">
<!-- Headline --> <span class="eyebrow-dot"></span>
<h1 class="hero-title mb-3 fade-in">Media Downloader</h1> <span>Media Downloader</span>
<p class="hero-subtitle text-white-50 mb-5 fade-in delay-1">
Simply paste your link. We handle the rest.
</p> </p>
<!-- Form Area --> <!-- Hero Headline — SSR, indexed by crawlers -->
<div class="form-container fade-in delay-2"> <h1 class="hero-title fade-in delay-1">
<form id="upload-form"> Download<br>anything.
</h1>
<p class="hero-sub fade-in delay-2">
Paste your link — we'll handle the rest.
</p>
<!-- SSR platform list (visible to crawlers, hidden visually) -->
<p class="sr-only">
Supports: {% for p in platforms %}{{ p }}{% if not loop.last %}, {% endif %}{% endfor %}.
Download as MP3 or MP4 with optional H.264 re-encoding and direct upload to your S3 bucket.
</p>
<!-- Download Form -->
<section class="form-container fade-in delay-3" aria-label="Download form">
<form id="upload-form" autocomplete="off" novalidate>
<input type="hidden" name="platform" id="input-platform" value="SoundCloud"> <input type="hidden" name="platform" id="input-platform" value="SoundCloud">
<!-- Main Input --> <!-- URL Input -->
<div class="glass-input-wrapper d-flex align-items-center shadow-lg"> <div class="input-row">
<div class="icon-zone ps-4 text-white-50"> <div class="glass-input-wrapper" id="input-wrapper">
<i class="fas fa-link" id="url-icon"></i> <span class="input-icon" id="url-icon" aria-hidden="true">
</div> <i class="fas fa-link"></i>
<input type="url" class="form-control bg-transparent border-0 text-white p-4 fs-5" </span>
id="url" name="url" required placeholder="https://..." autocomplete="off"> <label for="url" class="sr-only">Media URL</label>
<button class="btn btn-action p-4 fw-bold text-uppercase" type="submit" id="submit-button"> <input type="url" class="url-field" id="url" name="url" required
Start <i class="fas fa-arrow-right ms-2"></i> placeholder="https://soundcloud.com/..."
aria-label="Paste your media URL here" />
<button class="start-btn" type="submit" id="submit-button"
aria-label="Start download">
<span class="start-label">Start</span>
<i class="fas fa-arrow-right start-arrow" aria-hidden="true"></i>
<span class="btn-ripple" aria-hidden="true"></span>
</button> </button>
</div> </div>
</div>
<!-- Detected Platform & Options Slide-Down --> <!-- Platform detection badge + options -->
<div id="detection-area" class="mt-4 transition-all" style="opacity: 0; transform: translateY(-10px);"> <div id="detection-area" class="detection-area" aria-live="polite">
<div class="platform-badge" id="platform-badge" aria-label="Detected platform">
<!-- Badge --> <span class="platform-pulse" id="platform-pulse" aria-hidden="true"></span>
<span class="badge glass-badge px-3 py-2 rounded-pill mb-3"> <i id="detected-icon" class="fab fa-soundcloud" aria-hidden="true"></i>
<i id="detected-icon" class="fas fa-check me-2"></i>
<span id="detected-text">SoundCloud detected</span> <span id="detected-text">SoundCloud detected</span>
</span> </div>
<!-- Dynamic Options --> <div id="options-container" class="options-container" role="group" aria-label="Format options">
<div id="options-container" class="options-drawer mx-auto text-start text-white"> <!-- YouTube options -->
<div id="youtube-options" class="d-none option-block">
<!-- YouTube Options --> <div class="d-flex gap-4 justify-content-center flex-wrap">
<div id="youtube-options" class="d-none option-group"> <div class="option-col">
<div class="row g-4 justify-content-center"> <div class="option-label" id="format-label">Format</div>
<div class="col-auto"> <div class="switch-toggle" role="radiogroup" aria-labelledby="format-label">
<label class="text-white-50 small text-uppercase fw-bold mb-2 d-block">Format</label>
<div class="switch-toggle">
<input type="radio" name="yt_format" id="format-mp3" value="mp3" checked> <input type="radio" name="yt_format" id="format-mp3" value="mp3" checked>
<label for="format-mp3">MP3</label> <label for="format-mp3">MP3</label>
<input type="radio" name="yt_format" id="format-mp4" value="mp4"> <input type="radio" name="yt_format" id="format-mp4" value="mp4">
<label for="format-mp4">MP4</label> <label for="format-mp4">MP4</label>
<div class="toggle-bg"></div> <div class="toggle-pill" aria-hidden="true"></div>
</div> </div>
</div> </div>
<div class="col-auto" id="quality-wrapper"> <div class="option-col" id="quality-wrapper">
<label class="text-white-50 small text-uppercase fw-bold mb-2 d-block">Quality</label> <label class="option-label" for="mp3_bitrate">Quality</label>
<select class="form-select glass-select" id="mp3_bitrate" name="mp3_bitrate"> <select class="glass-select" id="mp3_bitrate" name="mp3_bitrate">
<option>Best</option><option selected>192k</option><option>128k</option> <option>Best</option>
<option selected>192k</option>
<option>128k</option>
<option>64k</option>
</select> </select>
<select class="form-select glass-select d-none" id="mp4_quality" name="mp4_quality"> <select class="glass-select d-none" id="mp4_quality" name="mp4_quality">
<option selected>Best</option><option>Medium</option><option>Low</option> <option selected>Best</option>
<option>Medium (~720p)</option>
<option>Low (~480p)</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<!-- Codec toggle -->
<!-- Codec Options --> <div id="codec-options-section" class="d-none option-block text-center">
<div id="codec-options-section" class="d-none option-group mt-3 text-center"> <div class="codec-row">
<div class="d-inline-flex align-items-center glass-panel px-3 py-2 rounded"> <label class="option-label" for="codec-switch">H.264 Compatibility</label>
<span class="text-white-50 small me-3">Compatibility (H.264)</span> <label class="toggle-switch">
<div class="form-check form-switch m-0"> <input class="ts-input" type="checkbox" role="switch"
<input class="form-check-input custom-switch" type="checkbox" role="switch" id="codec-switch"> id="codec-switch" aria-describedby="codec-desc">
<span class="ts-track"><span class="ts-thumb"></span></span>
<input type="hidden" name="codec_preference" id="codec_preference" value="original"> <input type="hidden" name="codec_preference" id="codec_preference" value="original">
</label>
</div>
<p id="codec-desc" class="sr-only">Re-encode video to H.264 for maximum device compatibility</p>
</div> </div>
</div> </div>
</div> </div>
</div> <!-- Error message -->
</div> <div id="error-message" class="error-alert d-none" role="alert" aria-live="assertive"></div>
<!-- Error Alert -->
<div id="error-message" class="alert glass-alert mt-4 d-none text-danger fw-bold" role="alert"></div>
</form> </form>
</div> </section>
<!-- Processing / Terminal View (Hidden initially) --> <!-- Processing view (JS-controlled, not indexed) -->
<div id="process-view" class="d-none w-100 max-w-lg mx-auto mt-5"> <section id="process-view" class="d-none process-view" aria-label="Processing status" aria-live="polite">
<div class="process-header">
<h2 id="status-message" class="status-msg">INITIALIZING...</h2>
<span id="progress-pct" class="progress-pct" aria-live="polite">0%</span>
</div>
<div class="progress-track" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="progress-bar" class="progress-fill" style="width:0%"></div>
</div>
<div class="terminal-window" aria-label="Processing log" role="log">
<div class="terminal-bar" aria-hidden="true">
<span class="tdot tdot-r"></span>
<span class="tdot tdot-y"></span>
<span class="tdot tdot-g"></span>
<span class="tbar-label">worker@{{ site_name|lower|replace(' ','') }} ~ process</span>
</div>
<div id="pseudo-log-content" class="terminal-body"></div>
</div>
</section>
<!-- Main Status --> <!-- Result view -->
<h3 id="status-message" class="text-white fw-bold mb-3 tracking-wide">INITIALIZING...</h3> <section id="result-view" class="d-none result-view" aria-label="Download result" aria-live="polite">
<div class="result-card">
<!-- Progress Bar --> <div class="result-icon-wrap success-ripple">
<div class="progress glass-progress mb-4"> <div class="result-icon" aria-hidden="true">
<div id="progress-bar" class="progress-bar bg-primary-gradient" style="width: 0%"></div> <i class="fas fa-check"></i>
</div>
<!-- CLI Log Window -->
<div class="terminal-window glass-panel text-start p-3 font-monospace small">
<div class="terminal-header d-flex gap-2 mb-2 opacity-50">
<div class="dot bg-danger"></div>
<div class="dot bg-warning"></div>
<div class="dot bg-success"></div>
<span class="ms-2">worker@unknown-dl:~# process_task</span>
</div>
<div id="pseudo-log-content" class="terminal-body text-primary-light">
<!-- Logs typing here -->
</div> </div>
</div> </div>
</div> <h2 class="result-title">Done!</h2>
<p class="result-sub">Your file is ready to download.</p>
<!-- Result View --> <a id="result-url" href="#" target="_blank" rel="noopener" class="btn-download">
<div id="result-view" class="d-none mt-5"> <i class="fas fa-download me-2" aria-hidden="true"></i>Download
<div class="result-card glass-panel p-5 d-inline-block rounded-4">
<div class="icon-circle bg-success-soft mb-3 mx-auto">
<i class="fas fa-check text-success fs-2"></i>
</div>
<h2 class="text-white fw-bold mb-1">Done!</h2>
<p class="text-white-50 mb-4">Your file is ready.</p>
<a id="result-url" href="#" target="_blank" class="btn btn-primary-glow btn-lg px-5 rounded-pill fw-bold">
<i class="fas fa-download me-2"></i> Download
</a> </a>
<div class="result-retry">
<div class="mt-4"> <button class="btn-retry" onclick="location.reload()" type="button">
<button class="btn btn-link text-white-50 text-decoration-none btn-sm" onclick="location.reload()"> <i class="fas fa-rotate-left me-1" aria-hidden="true"></i>Convert another
<i class="fas fa-redo me-1"></i> Convert another
</button> </button>
</div> </div>
</div> </div>
</div> </section>
</div> </div>
</main> </main>
<!-- Footer (SSR, indexable) -->
<footer class="site-footer" aria-label="Supported platforms">
<div class="footer-inner">
<p class="footer-platforms">
{% for p in platforms %}
<span class="footer-platform">{{ p }}</span>
{% endfor %}
</p>
<p class="footer-note">
{% if successful_jobs > 0 %}{{ successful_jobs }} successful downloads &nbsp;·&nbsp; {% endif %}
History stored locally in your browser only
</p>
</div>
</footer>
<!-- History Modal --> <!-- History Modal -->
<div class="modal fade" id="historyModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="historyModal" tabindex="-1"
aria-labelledby="historyModalTitle" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered"> <div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content glass-modal border-0"> <div class="modal-content glass-modal">
<div class="modal-header border-bottom-white-10"> <div class="modal-header">
<h5 class="modal-title text-white fw-bold">History (Local)</h5> <div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> <h3 class="modal-title" id="historyModalTitle">History</h3>
<p class="modal-hint">Stored locally in your browser · 7 days</p>
</div>
<button type="button" class="btn-close btn-close-white"
data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body p-0"> <div class="modal-body p-0">
<div class="table-responsive" style="max-height: 50vh;"> <div class="table-responsive" style="max-height:55vh;">
<table class="table table-dark table-hover mb-0 bg-transparent align-middle" id="history-table"> <table class="history-table" id="history-table">
<thead> <thead>
<tr class="text-white-50 small text-uppercase"> <tr>
<th class="ps-4">Time</th> <th class="ps-4" scope="col">Time</th>
<th class="text-center">Type</th> <th scope="col">Platform</th>
<th>Title</th> <th scope="col">Title</th>
<th class="text-center">Source</th> <th class="text-center" scope="col">Source</th>
<th class="text-end pe-4">Download</th> <th class="text-end pe-4" scope="col">File</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody></tbody>
<!-- JS Fills this -->
</tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="modal-footer border-top-white-10 justify-content-between"> <div class="modal-footer">
<button id="clear-history-button" class="btn btn-outline-danger btn-sm rounded-pill">Clear all</button> <button id="clear-history-button" class="btn-clear-history" type="button">
<small class="text-white-50">Saved in browser for 7 days</small> <i class="fas fa-trash-can me-1" aria-hidden="true"></i>Clear all
</button>
<span class="modal-footer-hint">Your data never leaves this browser</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- No-JS fallback -->
<noscript>
<div style="position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#06040f;color:white;font-family:sans-serif;text-align:center;padding:2rem;z-index:9999">
<p>{{ site_name }} requires JavaScript to work. Please enable it in your browser settings.</p>
</div>
</noscript>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='script.js') }}"></script> <script src="{{ url_for('static', filename='script.js') }}"></script>
</body> </body>