neue Filter nach Kategorien erstellt

This commit is contained in:
sandro committed 2026-09-01 23:37:34 +02:00
1 parent 65089447b5
commit 3ea5612bed
3 files changed
+1095 -46

No files matched your search

+204 -46
View File
@@ -3,6 +3,7 @@ import json
import io
import zipfile
import asyncio
import logging
from pathlib import Path
from flask import Flask, render_template_string, request, session, redirect, url_for, send_from_directory, send_file, jsonify
from flask_cors import CORS
@@ -13,17 +14,89 @@ import config
# Konfiguration
PASSWORD = config.PASSWORD
BASE_DIR = config.BASE_DIR
JSON_DB_PATH = getattr(config, 'JSON_DB_PATH', 'toniebox.json')
# Logging Setup
logging.basicConfig(
filename='toniecloud.log',
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
force=True
)
logger = logging.getLogger('tonieflix')
# Werkzeug-Zugriffslogs stummschalten
logging.getLogger('werkzeug').setLevel(logging.WARNING)
app = Flask(__name__)
app.config['SECRET_KEY'] = config.SECRET_KEY
CORS(app, supports_credentials=True, allow_headers="*")
# ---------------------------------------------------------
# JSON Datenbank verarbeiten
# ---------------------------------------------------------
def load_metadata():
"""Liest die JSON-Datei aus und ordnet Metadaten (Genre, Alter, Format) den Titeln zu."""
metadata = {}
if os.path.exists(JSON_DB_PATH):
try:
with open(JSON_DB_PATH, 'r', encoding='utf-8') as f:
raw_data = json.load(f)
for item in raw_data:
# Wir suchen spezifisch nach der Tabelle 'toniebox'
if item.get('type') == 'table' and item.get('name') == 'toniebox':
for row in item.get('data', []):
title = row.get('title')
if not title:
continue
# Wir normalisieren den Titel (Kleinbuchstaben, keine Leerzeichen am Rand),
# damit der Ordnername besser gematcht werden kann.
title_key = title.lower().strip()
if title_key not in metadata:
metadata[title_key] = {'genres': set(), 'formats': set(), 'agegroups': set()}
# Genres bereinigen (am | splitten und trimmen)
if row.get('genre'):
for g in str(row['genre']).split('|'):
clean_g = g.strip()
if clean_g:
metadata[title_key]['genres'].add(clean_g)
# Formate bereinigen (am | splitten und trimmen)
if row.get('format'):
for fmt in str(row['format']).split('|'):
clean_fmt = fmt.strip()
if clean_fmt:
metadata[title_key]['formats'].add(clean_fmt)
# Altersgruppen bereinigen (am | splitten und trimmen)
if row.get('agegroup'):
for age in str(row['agegroup']).split('|'):
clean_age = age.strip()
if clean_age:
metadata[title_key]['agegroups'].add(clean_age)
except Exception as e:
logger.error(f"Fehler beim Laden der JSON-Datenbank: {e}")
# Sets für die JSON-Serialisierung im Frontend in Listen umwandeln
for key, val in metadata.items():
val['genres'] = list(val['genres'])
val['formats'] = list(val['formats'])
val['agegroups'] = list(val['agegroups'])
return metadata
# ---------------------------------------------------------
# Toniecloud API & Playwright Hilfsfunktionen
# ---------------------------------------------------------
async def get_tonie_api_token(username: str, password: str) -> str | None:
"""Loggt sich automatisiert bei Tonies ein und gibt das Access-Token zurück."""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(viewport={'width': 1280, 'height': 800})
@@ -58,7 +131,7 @@ async def get_tonie_api_token(username: str, password: str) -> str | None:
await browser.close()
return access_token
except Exception as e:
print(f"Playwright Login-Fehler: {e}")
logger.error(f"Playwright Login-Fehler: {e}")
await browser.close()
return None
@@ -167,6 +240,8 @@ def upload_audio_to_creative_tonie(access_token: str, household_id: str, tonie_i
# ---------------------------------------------------------
def get_books(single_book=None):
books = []
metadata_lookup = load_metadata()
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:
@@ -175,8 +250,20 @@ def get_books(single_book=None):
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})
# Match der Metadaten basierend auf dem Ordnernamen
item_key = item.lower().strip()
meta = metadata_lookup.get(item_key, {'genres': [], 'formats': [], 'agegroups': []})
books.append({
'name': item,
'tracks': tracks,
'has_cover': has_cover,
'genres': meta['genres'],
'formats': meta['formats'],
'agegroups': meta['agegroups']
})
return books
@@ -195,16 +282,21 @@ TEMPLATE = """
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; display: flex; justify-content: center; gap: 15px; flex-wrap: wrap; }
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; }
.top-controls { text-align: center; margin-bottom: 20px; display: flex; justify-content: center; gap: 15px; flex-wrap: wrap; }
input[type="text"] { padding: 12px 20px; width: 100%; 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); }
.filter-bar { display: flex; justify-content: center; gap: 10px; margin-bottom: 30px; flex-wrap: wrap; }
.filter-bar select { padding: 10px 15px; border-radius: 20px; background: #222; color: #fff; border: 1px solid #333; font-size: 0.9rem; outline: none; cursor: pointer; }
.filter-bar select: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 .meta-info { font-size: 0.75rem; color: #888; text-align: center; padding: 0 10px 5px 10px; }
.card-actions { padding: 10px; text-align: center; display: flex; flex-direction: column; gap: 6px; }
.btn-direct, .btn-tonie { 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, .btn-tonie:hover { background: var(--primary); color: white; border-color: var(--primary); }
@@ -230,28 +322,17 @@ TEMPLATE = """
.logout:hover { color: #fff; }
/* Modal für Tonie-Upload */
#tonieModal {
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8); z-index: 2000; justify-content: center; align-items: center;
}
.modal-content {
background: #1f1f1f; padding: 30px; border-radius: 12px; width: 90%; max-width: 500px;
box-shadow: 0 5px 20px rgba(0,0,0,0.8); text-align: center; position: relative; max-height: 90vh; overflow-y: auto;
}
#tonieModal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 2000; justify-content: center; align-items: center; }
.modal-content { background: #1f1f1f; padding: 30px; border-radius: 12px; width: 90%; max-width: 500px; box-shadow: 0 5px 20px rgba(0,0,0,0.8); text-align: center; position: relative; max-height: 90vh; overflow-y: auto; }
.modal-close { position: absolute; top: 15px; right: 15px; font-size: 1.5rem; cursor: pointer; color: #aaa; }
.modal-close:hover { color: #fff; }
.tonie-list { display: flex; flex-direction: column; gap: 10px; margin-top: 20px; text-align: left; }
.tonie-item {
display: flex; align-items: center; gap: 15px; background: #2a2a2a; padding: 10px; border-radius: 8px; cursor: pointer; transition: 0.2s;
}
.tonie-item { display: flex; align-items: center; gap: 15px; background: #2a2a2a; padding: 10px; border-radius: 8px; cursor: pointer; transition: 0.2s; }
.tonie-item:hover { background: #3a3a3a; border: 1px solid var(--primary); }
.tonie-item img { width: 50px; height: 50px; border-radius: 6px; object-fit: cover; }
.checkbox-container { margin: 15px 0; text-align: left; display: flex; align-items: center; gap: 10px; font-size: 0.9rem; }
#loadingOverlay {
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85); z-index: 3000; flex-direction: column; justify-content: center; align-items: center; color: white;
}
#loadingOverlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 3000; flex-direction: column; justify-content: center; align-items: center; color: white; }
.spinner { border: 4px solid #333; border-top: 4px solid var(--primary); border-radius: 50%; width: 50px; height: 50px; animation: spin 1s linear infinite; margin-bottom: 20px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
@@ -269,9 +350,37 @@ TEMPLATE = """
{% endif %}
</div>
{% if not single_mode %}
<div class="filter-bar">
<select id="filterGenre">
<option value="">🎭 Alle Genres</option>
{% for g in all_genres %}
<option value="{{ g }}">{{ g }}</option>
{% endfor %}
</select>
<select id="filterAge">
<option value="">👶 Alle Altersgruppen</option>
{% for a in all_agegroups %}
<option value="{{ a }}">{{ a }}</option>
{% endfor %}
</select>
<select id="filterFormat">
<option value="">🎧 Alle Formate</option>
{% for f in all_formats %}
<option value="{{ f }}">{{ f }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="grid" id="bookGrid">
{% for book in books %}
<div class="card" data-title="{{ book.name.lower() }}">
<div class="card"
data-title="{{ book.name.lower() }}"
data-genres='{{ book.genres|tojson }}'
data-agegroups='{{ book.agegroups|tojson }}'
data-formats='{{ book.formats|tojson }}'>
<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">
@@ -279,6 +388,12 @@ TEMPLATE = """
<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>
{% if book.genres or book.formats or book.agegroups %}
<div class="meta-info">
{{ book.formats|join(', ') }} • {{ book.agegroups|join(', ') }}<br>
{{ book.genres|join(', ') }}
</div>
{% endif %}
</div>
<div class="card-actions">
<button class="btn-direct" data-name="{{ book.name }}">🔗 Direktlink</button>
@@ -290,32 +405,28 @@ TEMPLATE = """
{% endfor %}
</div>
<!-- Tonie Auswahl Modal -->
<!-- Tonie Auswahl Modal & Loading (unverändert) -->
<div id="tonieModal">
<div class="modal-content">
<span class="modal-close" onclick="closeTonieModal()">&times;</span>
<h2 style="color: var(--primary); margin-top: 0;">Kreativ-Tonie auswählen</h2>
<p id="modalBookTitle" style="color: #aaa; font-size: 0.9rem;"></p>
<div class="checkbox-container">
<input type="checkbox" id="deleteAllChapters" style="transform: scale(1.2);">
<label for="deleteAllChapters">Vorhandene Kapitel auf dem Tonie löschen</label>
</div>
<div class="tonie-list" id="tonieListContainer">
<p style="text-align: center; color: #aaa;">Lade Kreativ-Tonies...</p>
</div>
</div>
</div>
<!-- Ladebildschirm Upload -->
<div id="loadingOverlay">
<div class="spinner"></div>
<h2 id="loadingText">Lade Hörbuch auf Kreativ-Tonie hoch...</h2>
<p style="color: #aaa;">Bitte Fenster nicht schließen.</p>
</div>
<!-- Player Bar -->
<div class="player-bar" id="playerBar" style="display: none;">
<div class="player-info" id="playerInfo">Wird geladen...</div>
<div class="player-controls">
@@ -330,7 +441,11 @@ TEMPLATE = """
const audioPlayer = document.getElementById('audioPlayer');
const playerBar = document.getElementById('playerBar');
const playerInfo = document.getElementById('playerInfo');
const searchInput = document.getElementById('searchInput');
const filterGenre = document.getElementById('filterGenre');
const filterAge = document.getElementById('filterAge');
const filterFormat = document.getElementById('filterFormat');
const cards = document.querySelectorAll('.card');
const prevBtn = document.getElementById('prevBtn');
@@ -345,18 +460,38 @@ TEMPLATE = """
let selectedBookNameForTonie = '';
// 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';
});
// Kombinierte Such- und Filterlogik
function applyFilters() {
const term = searchInput ? searchInput.value.toLowerCase() : '';
const selGenre = filterGenre ? filterGenre.value : '';
const selAge = filterAge ? filterAge.value : '';
const selFormat = filterFormat ? filterFormat.value : '';
cards.forEach(card => {
const title = card.getAttribute('data-title');
const genres = JSON.parse(card.getAttribute('data-genres') || '[]');
const agegroups = JSON.parse(card.getAttribute('data-agegroups') || '[]');
const formats = JSON.parse(card.getAttribute('data-formats') || '[]');
const matchText = title.includes(term);
const matchGenre = selGenre === '' || genres.includes(selGenre);
const matchAge = selAge === '' || agegroups.includes(selAge);
const matchFormat = selFormat === '' || formats.includes(selFormat);
if (matchText && matchGenre && matchAge && matchFormat) {
card.style.display = 'flex';
} else {
card.style.display = 'none';
}
});
}
// Event-Listener für Karten
if (searchInput) searchInput.addEventListener('input', applyFilters);
if (filterGenre) filterGenre.addEventListener('change', applyFilters);
if (filterAge) filterAge.addEventListener('change', applyFilters);
if (filterFormat) filterFormat.addEventListener('change', applyFilters);
// Event-Listener für Karten (unverändert)
document.querySelectorAll('.card-clickable').forEach(el => {
el.addEventListener('click', () => {
const name = el.getAttribute('data-name');
@@ -415,9 +550,7 @@ TEMPLATE = """
});
}
function closeTonieModal() {
tonieModal.style.display = 'none';
}
function closeTonieModal() { tonieModal.style.display = 'none'; }
function uploadToTonie(tonieId) {
const deleteAll = document.getElementById('deleteAllChapters').checked;
@@ -568,12 +701,15 @@ def tonie_login():
if request.method == 'POST':
email = request.form.get('tonie_email')
pwd = request.form.get('tonie_password')
# Playwright Token holen (Asynchroner Aufruf im Sync Flask)
token = asyncio.run(get_tonie_api_token(email, pwd))
if token:
session['tonie_token'] = token
session['tonie_email'] = email
logger.info(f"Toniecloud-Login erfolgreich für E-Mail: {email} (IP: {request.remote_addr})")
return redirect(url_for('index'))
else:
logger.warning(f"Toniecloud-Login fehlgeschlagen für E-Mail: {email} (IP: {request.remote_addr})")
error = "Login fehlgeschlagen. Bitte Zugangsdaten prüfen."
return render_template_string(TONIE_LOGIN_TEMPLATE, error=error)
@@ -584,11 +720,32 @@ def logout():
@app.route('/')
def index():
return render_template_string(TEMPLATE, books=get_books(), auto_play="", single_mode=False)
books = get_books()
# Eindeutige Listen für Dropdowns sammeln
all_genres = set()
all_formats = set()
all_agegroups = set()
for b in books:
all_genres.update(b['genres'])
all_formats.update(b['formats'])
all_agegroups.update(b['agegroups'])
return render_template_string(
TEMPLATE,
books=books,
auto_play="",
single_mode=False,
all_genres=sorted(list(all_genres)),
all_formats=sorted(list(all_formats)),
all_agegroups=sorted(list(all_agegroups))
)
@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)
# Im Single Mode brauchen wir keine Dropdown-Daten
return render_template_string(TEMPLATE, books=get_books(single_book=book_name), auto_play=book_name, single_mode=True, all_genres=[], all_formats=[], all_agegroups=[])
@app.route('/cover/<path:book_name>')
def serve_cover(book_name):
@@ -634,10 +791,13 @@ def api_upload_to_tonie():
book_name = data.get('book_name')
tonie_id = data.get('tonie_id')
delete_all = data.get('delete_all', False)
user_email = session.get('tonie_email', 'Unbekannt')
if not book_name or not tonie_id:
return jsonify({"success": False, "error": "Fehlende Parameter."}), 400
logger.info(f"Start Upload von '{book_name}' auf Tonie {tonie_id} durch Benutzer: {user_email} (IP: {request.remote_addr})")
try:
household_id = get_household_id(token)
book_path = os.path.join(BASE_DIR, book_name)
@@ -649,13 +809,9 @@ def api_upload_to_tonie():
if not files:
return jsonify({"success": False, "error": "Keine Audiospuren im Hörbuch gefunden."}), 400
# Jede Spur nacheinander hochladen
for index, track in enumerate(files):
file_path = os.path.join(book_path, track)
# Titel des Kapitels (Dateiname ohne Endung oder schöner Name)
title = os.path.splitext(track)[0]
# Beim ersten Track wird ggf. der 'delete_all_chapters' Schalter angewendet, danach hängen wir an
should_delete = delete_all if index == 0 else False
upload_audio_to_creative_tonie(
@@ -667,10 +823,12 @@ def api_upload_to_tonie():
delete_all_chapters=should_delete
)
logger.info(f"Upload von '{book_name}' auf Tonie {tonie_id} durch {user_email} erfolgreich abgeschlossen.")
return jsonify({"success": True})
except Exception as e:
logger.error(f"Fehler beim Upload von '{book_name}' durch {user_email}: {str(e)}")
return jsonify({"success": False, "error": str(e)}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5005)
app.run(host='0.0.0.0', port=5005)