Files
toniecloud/app.backup.v0.2
T
2026-08-31 11:05:48 +02:00

441 lines
18 KiB
Plaintext

import os
import json
import io
import zipfile
from flask import Flask, render_template_string, request, session, redirect, url_for, send_from_directory, send_file, jsonify
from flask_cors import CORS
# Konfiguration
PASSWORD = 'toniessindteuer'
BASE_DIR = '/mnt/NAS/Toniebox/'
app = Flask(__name__)
app.config['SECRET_KEY'] = 'tonieflix-super-secret-key'
CORS(app, supports_credentials=True, allow_headers="*")
# ---------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------
def get_books(single_book=None):
books = []
if os.path.exists(BASE_DIR):
items_to_check = [single_book] if single_book else sorted(os.listdir(BASE_DIR))
for item in items_to_check:
item_path = os.path.join(BASE_DIR, item)
if os.path.isdir(item_path):
files = os.listdir(item_path)
tracks = sorted([f for f in files if f.lower().endswith(('.mp3', '.ogg'))])
has_cover = any(f.lower() == 'cover.jpg' for f in files)
if tracks:
books.append({
'name': item,
'tracks': tracks,
'has_cover': has_cover
})
return books
# ---------------------------------------------------------
# HTML & JS Template (Frontend)
# ---------------------------------------------------------
TEMPLATE = """
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tonieflix</title>
<style>
:root { --primary: #e50914; --bg: #141414; --card: #1f1f1f; --text: #ffffff; }
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: var(--bg); color: var(--text); margin: 0; padding: 20px; padding-bottom: 160px; }
h1 { text-align: center; color: var(--primary); font-size: 3rem; margin-bottom: 10px; font-weight: 800; }
.top-controls { text-align: center; margin-bottom: 30px; }
input[type="text"] { padding: 12px 20px; width: 80%; max-width: 400px; border: 1px solid #333; background: #222; color: #fff; border-radius: 25px; font-size: 1rem; outline: none; }
input[type="text"]:focus { border-color: var(--primary); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; max-width: 1200px; margin: 0 auto; }
.card { background: var(--card); border-radius: 12px; overflow: hidden; box-shadow: 0 4px 10px rgba(0,0,0,0.5); transition: transform 0.2s; display: flex; flex-direction: column; }
.card:hover { transform: scale(1.03); }
.card-clickable { cursor: pointer; flex-grow: 1; }
.card img { width: 100%; aspect-ratio: 1; object-fit: cover; background: #333; display: block; }
.card .title { padding: 12px 10px 5px 10px; text-align: center; font-weight: bold; font-size: 1rem; }
.card-actions { padding: 10px; text-align: center; }
.btn-direct { background: #333; color: #ccc; border: 1px solid #444; padding: 6px 14px; border-radius: 15px; font-size: 0.85rem; cursor: pointer; transition: 0.2s; }
.btn-direct:hover { background: var(--primary); color: white; border-color: var(--primary); }
/* Player Bar - Höher gesetzt für Handys mit Gestensteuerung */
.player-bar {
position: fixed;
bottom: 20px;
left: 15px;
right: 15px;
background: #181818;
border: 2px solid var(--primary);
border-radius: 16px;
padding: 15px 20px;
box-shadow: 0 -4px 20px rgba(0,0,0,0.8);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
z-index: 1000;
}
.player-info { font-weight: bold; color: var(--primary); font-size: 1.1rem; text-align: center; }
.player-controls { display: flex; align-items: center; gap: 15px; width: 100%; max-width: 800px; justify-content: center; flex-wrap: wrap; }
audio { flex-grow: 1; min-width: 250px; outline: none; }
.btn { border: none; padding: 10px 15px; border-radius: 25px; cursor: pointer; font-size: 1rem; font-weight: bold; transition: opacity 0.2s; }
.btn:hover { opacity: 0.8; }
.btn:disabled { background: #444; color: #777; cursor: not-allowed; opacity: 1; }
.btn-nav { background: var(--primary); color: white; text-decoration: none; display: inline-block; }
.btn-dl { background: #2ed573; color: white; text-decoration: none; display: inline-block; }
.logout { position: absolute; top: 20px; right: 20px; color: #aaa; text-decoration: none; font-weight: bold; }
.logout:hover { color: #fff; }
/* Autoplay Overlay */
#autoplayOverlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.9); z-index: 9999;
display: flex; flex-direction: column; justify-content: center; align-items: center;
color: white; cursor: pointer; display: none; text-align: center; padding: 20px; box-sizing: border-box;
}
.play-pulse {
font-size: 6rem; margin-bottom: 20px; text-shadow: 0 0 20px var(--primary);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
</style>
</head>
<body>
<a href="{{ url_for('logout') }}" class="logout">Abmelden</a>
<h1>🎬 Tonieflix</h1>
<div class="top-controls">
{% if single_mode %}
<a href="{{ url_for('index') }}" class="btn btn-nav" style="font-size: 1.1rem; padding: 12px 25px;">📚 Gesamte Bibliothek laden</a>
{% else %}
<input type="text" id="searchInput" placeholder="Hörbuch suchen...">
{% endif %}
</div>
<div class="grid" id="bookGrid">
{% for book in books %}
<div class="card" data-title="{{ book.name.lower() }}">
<div class="card-clickable" data-name="{{ book.name }}" data-tracks='{{ book.tracks|tojson }}'>
{% if book.has_cover %}
<img src="{{ url_for('serve_cover', book_name=book.name) }}" alt="Cover">
{% else %}
<img src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 1 1'></svg>" alt="Kein Cover">
{% endif %}
<div class="title">{{ book.name }}</div>
</div>
<div class="card-actions">
<button class="btn-direct" data-name="{{ book.name }}">🔗 Direktlink</button>
</div>
</div>
{% else %}
<p style="text-align: center; width: 100%; color: #aaa;">Keine Hörbücher gefunden.</p>
{% endfor %}
</div>
<div class="player-bar" id="playerBar" style="display: none;">
<div class="player-info" id="playerInfo">Wird geladen...</div>
<div class="player-controls">
<button class="btn btn-nav" id="prevBtn">⏮</button>
<audio id="audioPlayer" controls></audio>
<button class="btn btn-nav" id="nextBtn">⏭</button>
<a href="#" class="btn btn-dl" id="downloadBtn">⬇️ ZIP</a>
</div>
</div>
<!-- Overlay für Browser, die Autoplay blockieren -->
<div id="autoplayOverlay">
<div class="play-pulse">▶️</div>
<h2>Tippe irgendwo auf den Bildschirm, um zu starten</h2>
<h3 id="overlayBookName" style="color: var(--primary); margin-top: 10px;"></h3>
</div>
<script>
const audioPlayer = document.getElementById('audioPlayer');
const playerBar = document.getElementById('playerBar');
const playerInfo = document.getElementById('playerInfo');
const searchInput = document.getElementById('searchInput');
const cards = document.querySelectorAll('.card');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const downloadBtn = document.getElementById('downloadBtn');
const overlay = document.getElementById('autoplayOverlay');
const overlayBookName = document.getElementById('overlayBookName');
const autoPlayBookName = {{ auto_play|tojson }};
let currentBookState = { bookName: '', tracks: [], trackIndex: 0 };
// Suche
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const term = e.target.value.toLowerCase();
cards.forEach(card => {
const title = card.getAttribute('data-title');
card.style.display = title.includes(term) ? 'flex' : 'none';
});
});
}
// Event-Listener
document.querySelectorAll('.card-clickable').forEach(el => {
el.addEventListener('click', () => {
const name = el.getAttribute('data-name');
const tracks = JSON.parse(el.getAttribute('data-tracks'));
startBook(name, tracks);
});
});
document.querySelectorAll('.btn-direct').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const name = btn.getAttribute('data-name');
copyDirectLink(name);
});
});
// Overlay Klick (Startet Wiedergabe)
overlay.addEventListener('click', () => {
audioPlayer.play();
overlay.style.display = 'none';
});
prevBtn.addEventListener('click', prevTrack);
nextBtn.addEventListener('click', nextTrack);
// Direktlink kopieren
function copyDirectLink(bookName) {
const url = window.location.origin + '/play/' + encodeURIComponent(bookName);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(() => {
alert('Direktlink kopiert! URL:\\n\\n' + url);
}).catch(() => { prompt('Direktlink kopieren:', url); });
} else {
prompt('Direktlink kopieren:', url);
}
}
// Startet Hörbuch
function startBook(bookName, tracks) {
if (!tracks || tracks.length === 0) return;
if (currentBookState.bookName === bookName) {
audioPlayer.play().catch(e => console.warn(e));
return;
}
currentBookState = { bookName: bookName, tracks: tracks, trackIndex: 0 };
playerBar.style.display = 'flex';
loadTrackInfo(true);
}
function loadTrackInfo(autoPlay) {
const track = currentBookState.tracks[currentBookState.trackIndex];
audioPlayer.src = `/stream/${encodeURIComponent(currentBookState.bookName)}/${encodeURIComponent(track)}`;
updatePlayerUI();
const savedState = JSON.parse(localStorage.getItem('tonieflix_state'));
if (savedState && savedState.bookName === currentBookState.bookName && savedState.trackIndex === currentBookState.trackIndex) {
audioPlayer.currentTime = savedState.currentTime || 0;
} else {
audioPlayer.currentTime = 0;
}
if (autoPlay) {
const playPromise = audioPlayer.play();
if (playPromise !== undefined) {
playPromise.catch(error => {
console.warn("Autoplay blockiert vom Browser:", error);
overlayBookName.innerText = currentBookState.bookName;
overlay.style.display = 'flex';
});
}
}
}
function updatePlayerUI() {
const trackCount = currentBookState.tracks.length;
playerInfo.innerText = `${currentBookState.bookName} (Kapitel ${currentBookState.trackIndex + 1} von ${trackCount})`;
prevBtn.disabled = currentBookState.trackIndex === 0;
nextBtn.disabled = currentBookState.trackIndex === trackCount - 1;
downloadBtn.href = `/download/${encodeURIComponent(currentBookState.bookName)}`;
}
function prevTrack() {
if (currentBookState.trackIndex > 0) {
currentBookState.trackIndex--;
loadTrackInfo(true);
}
}
function nextTrack() {
if (currentBookState.trackIndex < currentBookState.tracks.length - 1) {
currentBookState.trackIndex++;
loadTrackInfo(true);
}
}
// Seite geladen
window.addEventListener('load', () => {
if (autoPlayBookName) {
const targetEl = document.querySelector(`.card-clickable[data-name="${CSS.escape(autoPlayBookName)}"]`);
if (targetEl) {
const tracks = JSON.parse(targetEl.getAttribute('data-tracks'));
startBook(autoPlayBookName, tracks);
return;
}
}
const savedState = localStorage.getItem('tonieflix_state');
if (savedState) {
currentBookState = JSON.parse(savedState);
playerBar.style.display = 'flex';
updatePlayerUI();
loadTrackInfo(false);
}
});
audioPlayer.addEventListener('timeupdate', () => {
localStorage.setItem('tonieflix_state', JSON.stringify({
...currentBookState,
currentTime: audioPlayer.currentTime
}));
});
audioPlayer.addEventListener('ended', () => {
if (currentBookState.trackIndex < currentBookState.tracks.length - 1) {
nextTrack();
} else {
playerInfo.innerText = `${currentBookState.bookName} - Beendet!`;
localStorage.removeItem('tonieflix_state');
}
});
</script>
</body>
</html>
"""
LOGIN_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<title>Login - Tonieflix</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background: #141414; color: #fff; margin: 0; }
.login-box { background: #1f1f1f; padding: 40px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.8); text-align: center; width: 100%; max-width: 320px; }
input[type="password"] { padding: 12px; margin: 15px 0; border: 1px solid #333; background: #2b2b2b; color: #fff; border-radius: 5px; width: 100%; box-sizing: border-box;}
button { background: #e50914; color: white; border: none; padding: 12px 20px; border-radius: 5px; cursor: pointer; width: 100%; font-size: 1rem; font-weight: bold; }
button:hover { opacity: 0.9; }
</style>
</head>
<body>
<div class="login-box">
<h2 style="color: #e50914; margin-top: 0;">🎬 Tonieflix</h2>
<form method="POST">
<input type="password" name="password" placeholder="Passwort" required autofocus>
<button type="submit">Eintreten</button>
</form>
</div>
</body>
</html>
"""
# ---------------------------------------------------------
# Backend Logik & Routen
# ---------------------------------------------------------
@app.before_request
def require_login():
if request.method == 'OPTIONS':
return None
if request.path.startswith('/api/'):
return None
if request.args.get('pw') == PASSWORD:
return None
if request.endpoint not in ['login', 'static'] and not session.get('logged_in'):
return redirect(url_for('login', next=request.full_path if request.full_path != '/' else None))
@app.route('/login', methods=['GET', 'POST'])
def login():
next_url = request.args.get('next') or url_for('index')
if request.method == 'POST':
if request.form.get('password') == PASSWORD:
session['logged_in'] = True
return redirect(next_url)
else:
return "Falsches Passwort!", 403
return render_template_string(LOGIN_TEMPLATE)
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('login'))
@app.route('/')
def index():
return render_template_string(TEMPLATE, books=get_books(), auto_play="", single_mode=False)
@app.route('/play/<path:book_name>')
def play_book(book_name):
return render_template_string(TEMPLATE, books=get_books(single_book=book_name), auto_play=book_name, single_mode=True)
@app.route('/cover/<path:book_name>')
def serve_cover(book_name):
book_path = os.path.join(BASE_DIR, book_name)
return send_from_directory(book_path, 'cover.jpg')
@app.route('/stream/<path:book_name>/<path:track_name>')
def serve_audio(book_name, track_name):
book_path = os.path.join(BASE_DIR, book_name)
return send_from_directory(book_path, track_name)
@app.route('/download/<path:book_name>')
def download_book(book_name):
book_path = os.path.join(BASE_DIR, book_name)
if not os.path.isdir(book_path):
return "Hörbuch nicht gefunden", 404
memory_file = io.BytesIO()
with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_STORED) as zf:
for root, dirs, files in os.walk(book_path):
for file in files:
file_path = os.path.join(root, file)
zf.write(file_path, arcname=file)
memory_file.seek(0)
return send_file(
memory_file,
download_name=f"{book_name}.zip",
as_attachment=True,
mimetype='application/zip'
)
@app.route('/api/books')
def api_books():
client_password = request.headers.get('X-Password')
if client_password != PASSWORD:
return jsonify({"error": "Falsches Passwort"}), 403
return jsonify(get_books())
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5005)