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)
+270 -216
View File
@@ -1,303 +1,357 @@
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'), platformInput: document.getElementById('input-platform'),
submitBtn: document.getElementById('submit-button'), detectionArea: document.getElementById('detection-area'),
platformInput: document.getElementById('input-platform'), detectedText: document.getElementById('detected-text'),
detectedIcon: document.getElementById('detected-icon'),
// Detection UI platformPulse: document.getElementById('platform-pulse'),
detectionArea: document.getElementById('detection-area'), platformBadge: document.getElementById('platform-badge'),
detectedText: document.getElementById('detected-text'), urlIcon: document.getElementById('url-icon'),
detectedIcon: document.getElementById('detected-icon'), inputWrapper: document.getElementById('input-wrapper'),
urlIcon: document.getElementById('url-icon'), ytOptions: document.getElementById('youtube-options'),
codecSection: document.getElementById('codec-options-section'),
// Options ytRadios: document.querySelectorAll('input[name="yt_format"]'),
optionsContainer: document.getElementById('options-container'), mp3Select: document.getElementById('mp3_bitrate'),
ytOptions: document.getElementById('youtube-options'), mp4Select: document.getElementById('mp4_quality'),
codecSection: document.getElementById('codec-options-section'), codecSwitch: document.getElementById('codec-switch'),
ytRadios: document.querySelectorAll('input[name="yt_format"]'), codecPref: document.getElementById('codec_preference'),
mp3Select: document.getElementById('mp3_bitrate'), processView: document.getElementById('process-view'),
mp4Select: document.getElementById('mp4_quality'), resultView: document.getElementById('result-view'),
codecSwitch: document.getElementById('codec-switch'), formContainer: document.querySelector('.form-container'),
codecPref: document.getElementById('codec_preference'), statusMsg: document.getElementById('status-message'),
progressBar: document.getElementById('progress-bar'),
// Views progressPct: document.getElementById('progress-pct'),
processView: document.getElementById('process-view'), logContent: document.getElementById('pseudo-log-content'),
resultView: document.getElementById('result-view'), resultUrl: document.getElementById('result-url'),
formContainer: document.querySelector('.form-container'), errorMsg: document.getElementById('error-message'),
historyTbody: document.querySelector('#history-table tbody'),
// Progress & Logs clearHistoryBtn:document.getElementById('clear-history-button'),
statusMsg: document.getElementById('status-message'),
progressBar: document.getElementById('progress-bar'),
logContent: document.getElementById('pseudo-log-content'),
// Result
resultUrl: document.getElementById('result-url'),
errorMsg: document.getElementById('error-message'),
// History
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 ---
function handleUrlInput() {
const url = dom.urlInput.value.trim();
const detected = platforms.find(p => p.pattern.test(url));
if (detected) {
dom.platformInput.value = detected.name;
dom.detectedText.textContent = detected.name + " detected";
dom.detectedIcon.className = `fab ${detected.icon}`;
dom.detectionArea.style.opacity = '1';
dom.detectionArea.style.transform = 'translateY(0)';
// Icon in Input
dom.urlIcon.className = `fab ${detected.icon}`;
dom.urlIcon.style.color = detected.color;
showOptions(detected.name);
} else {
if(url.length === 0) {
dom.detectionArea.style.opacity = '0';
dom.detectionArea.style.transform = 'translateY(-10px)';
dom.urlIcon.className = 'fas fa-link';
dom.urlIcon.style.color = '';
}
// Keep options hidden if no match
} }
} }
function showOptions(platform) { // ---- URL detection ----
function onUrlChange() {
const val = dom.urlInput.value.trim();
const match = PLATFORMS.find(p => p.pattern.test(val));
if (match) {
applyPlatform(match);
} else if (!val) {
resetPlatform();
}
}
function applyPlatform(p) {
dom.platformInput.value = p.name;
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;
// CSS vars for color theming
document.documentElement.style.setProperty('--platform-color', p.color);
document.documentElement.style.setProperty('--platform-rgb', p.rgb);
// Badge colors
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.detectionArea.classList.remove('visible');
}
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); s.logs.slice(lastLogIndex).forEach(line => addLine(line, 'is-real'));
lastLogIndex = s.logs.length;
} }
} catch (e) {
console.error(e); 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.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.processView.classList.add('d-none');
dom.formContainer.classList.remove('d-none'); // Back to form 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
pseudoLogInterval = setInterval(addLine, 2000);
} }
// --- Local History --- function readHistory() {
function loadHistory() { try {
const raw = localStorage.getItem('mdl_history'); const raw = localStorage.getItem(LS_KEY);
if(!raw) { if (!raw) return [];
dom.historyTableBody.innerHTML = '<tr><td colspan="5" class="text-center text-white-50 py-3">No history available</td></tr>'; const data = JSON.parse(raw).filter(i => Date.now() - i.ts < TTL);
localStorage.setItem(LS_KEY, JSON.stringify(data));
return data;
} catch { return []; }
}
function renderHistory() {
const data = readHistory();
if (!dom.historyTbody) return;
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) { function clearHistory() {
const pf = dom.platformInput.value; if (!confirm('Clear all download history?')) return;
const sourceUrl = dom.urlInput.value; // Capture source URL localStorage.removeItem(LS_KEY);
renderHistory();
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 escHtml(s) {
if(confirm("Really clear history?")) { return String(s)
localStorage.removeItem('mdl_history'); .replace(/&/g,'&amp;').replace(/</g,'&lt;')
loadHistory(); .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>
<div class="bg-glow bg-glow-3" aria-hidden="true"></div>
<div class="bg-noise" aria-hidden="true"></div>
<div class="particles" aria-hidden="true">
<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>
<!-- Header / Nav --> <!-- Header -->
<nav class="navbar fixed-top w-100 p-4 d-flex justify-content-between align-items-center z-3"> <header>
<div class="brand fw-bold text-white fs-4 tracking-wide"> <nav class="navbar fixed-top w-100 px-4 py-3 d-flex justify-content-between align-items-center"
unknownMedien<span class="text-primary-light">.dl</span> role="navigation" aria-label="Main navigation">
</div> <a href="/" class="brand text-decoration-none">
<button class="btn btn-glass-icon" data-bs-toggle="modal" data-bs-target="#historyModal" title="History"> unknown<span class="brand-accent">Medien</span><span class="brand-dot">.dl</span>
<i class="fas fa-history"></i> </a>
</button> <button class="btn btn-glass-icon" data-bs-toggle="modal" data-bs-target="#historyModal"
</nav> title="View download history" aria-label="Open history">
<i class="fas fa-clock-rotate-left" aria-hidden="true"></i>
</button>
</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">
<i class="fas fa-link"></i>
</span>
<label for="url" class="sr-only">Media URL</label>
<input type="url" class="url-field" id="url" name="url" required
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>
</div> </div>
<input type="url" class="form-control bg-transparent border-0 text-white p-4 fs-5"
id="url" name="url" required placeholder="https://..." autocomplete="off">
<button class="btn btn-action p-4 fw-bold text-uppercase" type="submit" id="submit-button">
Start <i class="fas fa-arrow-right ms-2"></i>
</button>
</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">
</div> </label>
</div> </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>
<!-- Error Alert --> <!-- Error message -->
<div id="error-message" class="alert glass-alert mt-4 d-none text-danger fw-bold" role="alert"></div> <div id="error-message" class="error-alert d-none" role="alert" aria-live="assertive"></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">
<!-- Main Status --> <h2 id="status-message" class="status-msg">INITIALIZING...</h2>
<h3 id="status-message" class="text-white fw-bold mb-3 tracking-wide">INITIALIZING...</h3> <span id="progress-pct" class="progress-pct" aria-live="polite">0%</span>
<!-- Progress Bar -->
<div class="progress glass-progress mb-4">
<div id="progress-bar" class="progress-bar bg-primary-gradient" style="width: 0%"></div>
</div> </div>
<div class="progress-track" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<!-- CLI Log Window --> <div id="progress-bar" class="progress-fill" style="width:0%"></div>
<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 class="terminal-window" aria-label="Processing log" role="log">
<div class="terminal-bar" aria-hidden="true">
<!-- Result View --> <span class="tdot tdot-r"></span>
<div id="result-view" class="d-none mt-5"> <span class="tdot tdot-y"></span>
<div class="result-card glass-panel p-5 d-inline-block rounded-4"> <span class="tdot tdot-g"></span>
<div class="icon-circle bg-success-soft mb-3 mx-auto"> <span class="tbar-label">worker@{{ site_name|lower|replace(' ','') }} ~ process</span>
<i class="fas fa-check text-success fs-2"></i>
</div> </div>
<h2 class="text-white fw-bold mb-1">Done!</h2> <div id="pseudo-log-content" class="terminal-body"></div>
<p class="text-white-50 mb-4">Your file is ready.</p> </div>
</section>
<a id="result-url" href="#" target="_blank" class="btn btn-primary-glow btn-lg px-5 rounded-pill fw-bold"> <!-- Result view -->
<i class="fas fa-download me-2"></i> Download <section id="result-view" class="d-none result-view" aria-label="Download result" aria-live="polite">
<div class="result-card">
<div class="result-icon-wrap success-ripple">
<div class="result-icon" aria-hidden="true">
<i class="fas fa-check"></i>
</div>
</div>
<h2 class="result-title">Done!</h2>
<p class="result-sub">Your file is ready to download.</p>
<a id="result-url" href="#" target="_blank" rel="noopener" class="btn-download">
<i class="fas fa-download me-2" aria-hidden="true"></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>