Erster Commit
This commit is contained in:
+320
@@ -0,0 +1,320 @@
|
||||
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
|
||||
|
||||
app = Flask(__name__)
|
||||
# WICHTIG: Ändere diesen Key für den produktiven Einsatz
|
||||
app.secret_key = '6a305032-9668-43c3-9a72-a407d5be988f'
|
||||
|
||||
# Konfiguration
|
||||
PASSWORD = 'toniessindteuer'
|
||||
BASE_DIR = '/mnt/NAS/Toniebox/'
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 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: #ff4757; --bg: #f1f2f6; --card: #ffffff; --text: #2f3542; }
|
||||
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; }
|
||||
.search-container { text-align: center; margin-bottom: 30px; }
|
||||
input[type="text"] { padding: 10px 20px; width: 80%; max-width: 400px; border: 2px solid #ddd; 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: 15px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); cursor: pointer; transition: transform 0.2s; }
|
||||
.card:hover { transform: translateY(-5px); }
|
||||
.card img { width: 100%; aspect-ratio: 1; object-fit: cover; background: #ddd; }
|
||||
.card .title { padding: 15px; text-align: center; font-weight: bold; font-size: 1.1rem; }
|
||||
|
||||
/* Player Bar */
|
||||
.player-bar { position: fixed; bottom: 0; left: 0; right: 0; background: var(--card); padding: 15px 20px; box-shadow: 0 -2px 15px rgba(0,0,0,0.15); 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: #ccc; cursor: not-allowed; opacity: 1; }
|
||||
|
||||
.btn-nav { background: var(--primary); color: white; }
|
||||
.btn-dl { background: #2ed573; color: white; text-decoration: none; display: inline-block; }
|
||||
|
||||
.logout { position: absolute; top: 20px; right: 20px; color: #57606f; text-decoration: none; font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="{{ url_for('logout') }}" class="logout">Abmelden</a>
|
||||
<h1>🎧 Tonieflix</h1>
|
||||
|
||||
<div class="search-container">
|
||||
<input type="text" id="searchInput" placeholder="Hörbuch suchen...">
|
||||
</div>
|
||||
|
||||
<div class="grid" id="bookGrid">
|
||||
{% for book in books %}
|
||||
<div class="card" data-title="{{ book.name.lower() }}" onclick='startBook({{ book|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='%23ddd' viewBox='0 0 1 1'></svg>" alt="Kein Cover">
|
||||
{% endif %}
|
||||
<div class="title">{{ book.name }}</div>
|
||||
</div>
|
||||
{% 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" onclick="prevTrack()">⏮ Zurück</button>
|
||||
<audio id="audioPlayer" controls></audio>
|
||||
<button class="btn btn-nav" id="nextBtn" onclick="nextTrack()">Weiter ⏭</button>
|
||||
<a href="#" class="btn btn-dl" id="downloadBtn">⬇️ Buch als ZIP laden</a>
|
||||
</div>
|
||||
</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');
|
||||
|
||||
let currentBookState = { bookName: '', tracks: [], trackIndex: 0 };
|
||||
|
||||
// Suchfunktion
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const term = e.target.value.toLowerCase();
|
||||
cards.forEach(card => {
|
||||
if (card.dataset.title.includes(term)) {
|
||||
card.style.display = 'block';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Status aus localStorage laden
|
||||
window.addEventListener('load', () => {
|
||||
const savedState = localStorage.getItem('toniboni_state');
|
||||
if (savedState) {
|
||||
currentBookState = JSON.parse(savedState);
|
||||
playerBar.style.display = 'flex';
|
||||
updatePlayerUI();
|
||||
loadTrackInfo(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Hörbuch starten
|
||||
function startBook(bookData) {
|
||||
if (!bookData.tracks || bookData.tracks.length === 0) {
|
||||
alert("Keine Audiodateien in diesem Ordner gefunden!");
|
||||
return;
|
||||
}
|
||||
if (currentBookState.bookName === bookData.name) {
|
||||
audioPlayer.play();
|
||||
return;
|
||||
}
|
||||
|
||||
currentBookState = {
|
||||
bookName: bookData.name,
|
||||
tracks: bookData.tracks,
|
||||
trackIndex: 0
|
||||
};
|
||||
playerBar.style.display = 'flex';
|
||||
loadTrackInfo(true);
|
||||
}
|
||||
|
||||
// Track laden & UI aktualisieren
|
||||
function loadTrackInfo(autoPlay) {
|
||||
const track = currentBookState.tracks[currentBookState.trackIndex];
|
||||
audioPlayer.src = `/stream/${encodeURIComponent(currentBookState.bookName)}/${encodeURIComponent(track)}`;
|
||||
|
||||
updatePlayerUI();
|
||||
|
||||
const savedState = JSON.parse(localStorage.getItem('toniboni_state'));
|
||||
if (savedState && savedState.bookName === currentBookState.bookName && savedState.trackIndex === currentBookState.trackIndex) {
|
||||
audioPlayer.currentTime = savedState.currentTime || 0;
|
||||
} else {
|
||||
audioPlayer.currentTime = 0;
|
||||
}
|
||||
|
||||
if (autoPlay) {
|
||||
audioPlayer.play();
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlayerUI() {
|
||||
const trackCount = currentBookState.tracks.length;
|
||||
playerInfo.innerText = `${currentBookState.bookName} (Kapitel ${currentBookState.trackIndex + 1} von ${trackCount})`;
|
||||
|
||||
// Buttons de/aktivieren
|
||||
prevBtn.disabled = currentBookState.trackIndex === 0;
|
||||
nextBtn.disabled = currentBookState.trackIndex === trackCount - 1;
|
||||
|
||||
// Download-Link anpassen
|
||||
downloadBtn.href = `/download/${encodeURIComponent(currentBookState.bookName)}`;
|
||||
}
|
||||
|
||||
// Navigation
|
||||
function prevTrack() {
|
||||
if (currentBookState.trackIndex > 0) {
|
||||
currentBookState.trackIndex--;
|
||||
loadTrackInfo(true);
|
||||
}
|
||||
}
|
||||
|
||||
function nextTrack() {
|
||||
if (currentBookState.trackIndex < currentBookState.tracks.length - 1) {
|
||||
currentBookState.trackIndex++;
|
||||
loadTrackInfo(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Speichern
|
||||
audioPlayer.addEventListener('timeupdate', () => {
|
||||
localStorage.setItem('toniboni_state', JSON.stringify({
|
||||
...currentBookState,
|
||||
currentTime: audioPlayer.currentTime
|
||||
}));
|
||||
});
|
||||
|
||||
// Automatisch nächstes Kapitel
|
||||
audioPlayer.addEventListener('ended', () => {
|
||||
if (currentBookState.trackIndex < currentBookState.tracks.length - 1) {
|
||||
nextTrack();
|
||||
} else {
|
||||
playerInfo.innerText = `${currentBookState.bookName} - Beendet!`;
|
||||
localStorage.removeItem('toniboni_state');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
LOGIN_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Login - Toniboni</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: #f1f2f6; margin: 0; }
|
||||
.login-box { background: white; padding: 40px; border-radius: 15px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); text-align: center; }
|
||||
input[type="password"] { padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 5px; width: 100%; box-sizing: border-box;}
|
||||
button { background: #ff4757; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; width: 100%; font-size: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h2 style="color: #ff4757;">Toniboni Login</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.endpoint not in ['login'] and not session.get('logged_in'):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
if request.form.get('password') == PASSWORD:
|
||||
session['logged_in'] = True
|
||||
return redirect(url_for('index'))
|
||||
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():
|
||||
books = []
|
||||
if os.path.exists(BASE_DIR):
|
||||
for item in sorted(os.listdir(BASE_DIR)):
|
||||
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 render_template_string(TEMPLATE, books=books)
|
||||
|
||||
@app.route('/cover/<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/<book_name>/<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/<book_name>')
|
||||
def download_book(book_name):
|
||||
book_path = os.path.join(BASE_DIR, book_name)
|
||||
|
||||
# Sicherheitscheck: Existiert der Ordner?
|
||||
if not os.path.isdir(book_path):
|
||||
return "Hörbuch nicht gefunden", 404
|
||||
|
||||
# ZIP-Datei im Arbeitsspeicher erstellen
|
||||
memory_file = io.BytesIO()
|
||||
|
||||
# ZIP_STORED nutzt keine Kompression (geht extrem schnell, gut für MP3s)
|
||||
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)
|
||||
# Schreibe Datei ins ZIP (nutzt Dateinamen ohne den absoluten NAS-Pfad)
|
||||
zf.write(file_path, arcname=file)
|
||||
|
||||
memory_file.seek(0)
|
||||
|
||||
# Datei an den Browser schicken
|
||||
return send_file(
|
||||
memory_file,
|
||||
download_name=f"{book_name}.zip",
|
||||
as_attachment=True,
|
||||
mimetype='application/zip'
|
||||
)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5005)
|
||||
Reference in New Issue
Block a user