This commit is contained in:
sandro committed 2026-10-11 20:10:16 +02:00
1 parent d75cb0bf76
commit 160dabc59d
1 file changed
+226 -58
+226 -58
View File
@@ -9,6 +9,9 @@ from flask import Flask, render_template_string, request, session, redirect, url
from flask_cors import CORS from flask_cors import CORS
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
import requests import requests
import mutagen
from mutagen.mp3 import MP3
from mutagen.oggvorbis import OggVorbis
import config import config
# Konfiguration # Konfiguration
@@ -34,11 +37,25 @@ app.config['SECRET_KEY'] = config.SECRET_KEY
CORS(app, supports_credentials=True, allow_headers="*") CORS(app, supports_credentials=True, allow_headers="*")
# ---------------------------------------------------------
# Hilfsfunktion: Exakte Track-Dauer ermitteln (Fehlertolerant)
# ---------------------------------------------------------
def get_track_duration(file_path: str) -> float:
try:
# Generischer Mutagen-Fallback, der mit MP3 und OGG/Vorbis/Opus umgehen kann
audio = mutagen.File(file_path)
if audio and hasattr(audio, 'info') and hasattr(audio.info, 'length'):
return audio.info.length
except Exception as e:
logger.warning(f"Konnte Dauer für {file_path} nicht auslesen: {e}")
log_to_db("WARNUNG", f"Konnte Dauer für {file_path} nicht auslesen: {e}")
return 0.0
# --------------------------------------------------------- # ---------------------------------------------------------
# JSON Datenbank verarbeiten # JSON Datenbank verarbeiten
# --------------------------------------------------------- # ---------------------------------------------------------
def load_metadata(): def load_metadata():
"""Liest die JSON-Datei aus und ordnet Metadaten (Genre, Alter, Format) den Titeln zu."""
metadata = {} metadata = {}
if os.path.exists(JSON_DB_PATH): if os.path.exists(JSON_DB_PATH):
try: try:
@@ -46,45 +63,35 @@ def load_metadata():
raw_data = json.load(f) raw_data = json.load(f)
for item in raw_data: for item in raw_data:
# Wir suchen spezifisch nach der Tabelle 'toniebox'
if item.get('type') == 'table' and item.get('name') == 'toniebox': if item.get('type') == 'table' and item.get('name') == 'toniebox':
for row in item.get('data', []): for row in item.get('data', []):
title = row.get('title') title = row.get('title')
if not title: if not title:
continue continue
# Wir normalisieren den Titel (Kleinbuchstaben, keine Leerzeichen am Rand),
# damit der Ordnername besser gematcht werden kann.
title_key = title.lower().strip() title_key = title.lower().strip()
if title_key not in metadata: if title_key not in metadata:
metadata[title_key] = {'genres': set(), 'formats': set(), 'agegroups': set()} metadata[title_key] = {'genres': set(), 'formats': set(), 'agegroups': set()}
# Genres bereinigen (am | splitten und trimmen)
if row.get('genre'): if row.get('genre'):
for g in str(row['genre']).split('|'): for g in str(row['genre']).split('|'):
clean_g = g.strip() clean_g = g.strip()
if clean_g: if clean_g: metadata[title_key]['genres'].add(clean_g)
metadata[title_key]['genres'].add(clean_g)
# Formate bereinigen (am | splitten und trimmen)
if row.get('format'): if row.get('format'):
for fmt in str(row['format']).split('|'): for fmt in str(row['format']).split('|'):
clean_fmt = fmt.strip() clean_fmt = fmt.strip()
if clean_fmt: if clean_fmt: metadata[title_key]['formats'].add(clean_fmt)
metadata[title_key]['formats'].add(clean_fmt)
# Altersgruppen bereinigen (am | splitten und trimmen)
if row.get('agegroup'): if row.get('agegroup'):
for age in str(row['agegroup']).split('|'): for age in str(row['agegroup']).split('|'):
clean_age = age.strip() clean_age = age.strip()
if clean_age: if clean_age: metadata[title_key]['agegroups'].add(clean_age)
metadata[title_key]['agegroups'].add(clean_age)
except Exception as e: except Exception as e:
logger.error(f"Fehler beim Laden der JSON-Datenbank: {e}") logger.error(f"Fehler beim Laden der JSON-Datenbank: {e}")
log_to_db("WARNUNG", 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(): for key, val in metadata.items():
val['genres'] = list(val['genres']) val['genres'] = list(val['genres'])
val['formats'] = list(val['formats']) val['formats'] = list(val['formats'])
@@ -132,6 +139,7 @@ async def get_tonie_api_token(username: str, password: str) -> str | None:
return access_token return access_token
except Exception as e: except Exception as e:
logger.error(f"Playwright Login-Fehler: {e}") logger.error(f"Playwright Login-Fehler: {e}")
log_to_db("FEHLER", f"Playwright Login-Fehler: {e}")
await browser.close() await browser.close()
return None return None
@@ -162,7 +170,6 @@ def get_creative_tonies(access_token: str, household_id: str) -> list[dict]:
def upload_audio_to_creative_tonie(access_token: str, household_id: str, tonie_id: str, file_path: str, title: str, delete_all_chapters: bool): def upload_audio_to_creative_tonie(access_token: str, household_id: str, tonie_id: str, file_path: str, title: str, delete_all_chapters: bool):
headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json", "User-Agent": "Mozilla/5.0"} headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json", "User-Agent": "Mozilla/5.0"}
# 1. S3 Berechtigung anfordern
upload_req_resp = requests.post("https://api.tonie.cloud/v2/file", json={"headers": {}}, headers=headers) upload_req_resp = requests.post("https://api.tonie.cloud/v2/file", json={"headers": {}}, headers=headers)
if upload_req_resp.status_code != 200: if upload_req_resp.status_code != 200:
raise Exception(f"Upload-Request fehlgeschlagen: {upload_req_resp.text}") raise Exception(f"Upload-Request fehlgeschlagen: {upload_req_resp.text}")
@@ -173,7 +180,6 @@ def upload_audio_to_creative_tonie(access_token: str, household_id: str, tonie_i
file_id = amazon_data.get("fileId") or amazon_data.get("fileID") or amazon_data.get("file") file_id = amazon_data.get("fileId") or amazon_data.get("fileID") or amazon_data.get("file")
s3_url = s3_request.get("url", "https://bxn-toniecloud-prod-upload.s3.amazonaws.com/") s3_url = s3_request.get("url", "https://bxn-toniecloud-prod-upload.s3.amazonaws.com/")
# 2. Datei zu S3 hochladen
if not Path(file_path).exists(): if not Path(file_path).exists():
raise FileNotFoundError(f"Datei {file_path} nicht gefunden.") raise FileNotFoundError(f"Datei {file_path} nicht gefunden.")
@@ -194,7 +200,6 @@ def upload_audio_to_creative_tonie(access_token: str, household_id: str, tonie_i
if s3_resp.status_code not in (200, 204): if s3_resp.status_code not in (200, 204):
raise Exception(f"S3-Upload fehlgeschlagen: {s3_resp.text}") raise Exception(f"S3-Upload fehlgeschlagen: {s3_resp.text}")
# 3. Tonie-Details abrufen & Kapitel aktualisieren
tonie_detail_resp = requests.get(f"https://api.tonie.cloud/v2/households/{household_id}/creativetonies/{tonie_id}", headers=headers) tonie_detail_resp = requests.get(f"https://api.tonie.cloud/v2/households/{household_id}/creativetonies/{tonie_id}", headers=headers)
if tonie_detail_resp.status_code != 200: if tonie_detail_resp.status_code != 200:
raise Exception(f"Konnte Tonie-Details nicht abrufen: {tonie_detail_resp.text}") raise Exception(f"Konnte Tonie-Details nicht abrufen: {tonie_detail_resp.text}")
@@ -212,7 +217,6 @@ def upload_audio_to_creative_tonie(access_token: str, household_id: str, tonie_i
if c_id and c_file and str(c_file).lower() != "none": if c_id and c_file and str(c_file).lower() != "none":
cleaned_chapters.append({"id": str(c_id), "file": str(c_file), "title": str(c_title)}) cleaned_chapters.append({"id": str(c_id), "file": str(c_file), "title": str(c_title)})
# Neues Kapitel anhängen
cleaned_chapters.append({ cleaned_chapters.append({
"id": str(fields.get("key")), "id": str(fields.get("key")),
"file": str(file_id), "file": str(file_id),
@@ -236,7 +240,7 @@ def upload_audio_to_creative_tonie(access_token: str, household_id: str, tonie_i
# --------------------------------------------------------- # ---------------------------------------------------------
# Bibliothek abrufen # Bibliothek abrufen (Nur Grundstruktur, superschnell)
# --------------------------------------------------------- # ---------------------------------------------------------
def get_books(single_book=None): def get_books(single_book=None):
books = [] books = []
@@ -248,17 +252,16 @@ def get_books(single_book=None):
item_path = os.path.join(BASE_DIR, item) item_path = os.path.join(BASE_DIR, item)
if os.path.isdir(item_path): if os.path.isdir(item_path):
files = os.listdir(item_path) files = os.listdir(item_path)
tracks = sorted([f for f in files if f.lower().endswith(('.mp3', '.ogg'))]) track_files = sorted([f for f in files if f.lower().endswith(('.mp3', '.ogg'))])
has_cover = any(f.lower() == 'cover.jpg' for f in files) has_cover = any(f.lower() == 'cover.jpg' for f in files)
if tracks: if track_files:
# Match der Metadaten basierend auf dem Ordnernamen
item_key = item.lower().strip() item_key = item.lower().strip()
meta = metadata_lookup.get(item_key, {'genres': [], 'formats': [], 'agegroups': []}) meta = metadata_lookup.get(item_key, {'genres': [], 'formats': [], 'agegroups': []})
books.append({ books.append({
'name': item, 'name': item,
'tracks': tracks, 'tracks': track_files,
'has_cover': has_cover, 'has_cover': has_cover,
'genres': meta['genres'], 'genres': meta['genres'],
'formats': meta['formats'], 'formats': meta['formats'],
@@ -290,7 +293,6 @@ TEMPLATE = """
.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 { 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); } .filter-bar select:focus { border-color: var(--primary); }
/* Desktop Grid: max-width auf 1600px erhöht, minmax auf 180px für bis zu 7 Items */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 20px; max-width: 1600px; margin: 0 auto; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 20px; max-width: 1600px; 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 { 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:hover { transform: scale(1.03); }
@@ -302,20 +304,14 @@ TEMPLATE = """
.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, .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); } .btn-direct:hover, .btn-tonie:hover { background: var(--primary); color: white; border-color: var(--primary); }
/* Mobile Anpassungen (für Bildschirme schmaler als 600px) */
@media (max-width: 600px) { @media (max-width: 600px) {
body { padding: 10px; padding-bottom: 160px; } body { padding: 10px; padding-bottom: 160px; }
h1 { font-size: 2.2rem; } h1 { font-size: 2.2rem; }
/* Grid auf Handy: minmax auf 110px reduziert -> ergibt 2-3 Spalten */
.grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; } .grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; }
/* Schriften und Abstände kompakter machen */
.card .title { font-size: 0.85rem; padding: 8px 5px 2px 5px; } .card .title { font-size: 0.85rem; padding: 8px 5px 2px 5px; }
.card .meta-info { font-size: 0.65rem; padding: 0 5px 5px 5px; } .card .meta-info { font-size: 0.65rem; padding: 0 5px 5px 5px; }
.card-actions { padding: 8px; gap: 4px; } .card-actions { padding: 8px; gap: 4px; }
.btn-direct, .btn-tonie { font-size: 0.75rem; padding: 4px 8px; } .btn-direct, .btn-tonie { font-size: 0.75rem; padding: 4px 8px; }
.filter-bar select { font-size: 0.8rem; padding: 8px 12px; } .filter-bar select { font-size: 0.8rem; padding: 8px 12px; }
} }
@@ -339,16 +335,24 @@ TEMPLATE = """
.logout { position: absolute; top: 20px; right: 20px; color: #aaa; text-decoration: none; font-weight: bold; } .logout { position: absolute; top: 20px; right: 20px; color: #aaa; text-decoration: none; font-weight: bold; }
.logout:hover { color: #fff; } .logout:hover { color: #fff; }
/* Modal für Tonie-Upload */ /* Modal für Tonie-Upload & Kapitelübersicht */
#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; } #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-content { background: #1f1f1f; padding: 30px; border-radius: 12px; width: 90%; max-width: 550px; box-shadow: 0 5px 20px rgba(0,0,0,0.8); text-align: left; 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 { position: absolute; top: 15px; right: 15px; font-size: 1.5rem; cursor: pointer; color: #aaa; }
.modal-close:hover { color: #fff; } .modal-close:hover { color: #fff; }
.tonie-list { display: flex; flex-direction: column; gap: 10px; margin-top: 20px; text-align: left; }
.tonie-list { display: flex; flex-direction: column; gap: 8px; margin-top: 15px; }
.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:hover { background: #3a3a3a; border: 1px solid var(--primary); }
.tonie-item img { width: 50px; height: 50px; border-radius: 6px; object-fit: cover; } .tonie-item img { width: 50px; height: 50px; border-radius: 6px; object-fit: cover; }
.chapter-list { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; max-height: 250px; overflow-y: auto; background: #141414; padding: 10px; border-radius: 8px; border: 1px solid #333; }
.chapter-item { display: flex; align-items: center; justify-content: space-between; font-size: 0.85rem; padding: 4px 0; border-bottom: 1px solid #222; }
.chapter-item label { display: flex; align-items: center; justify-content: space-between; cursor: pointer; width: 100%; gap: 10px; }
.chapter-info-text { color: #888; font-size: 0.75rem; white-space: nowrap; }
.checkbox-container { margin: 15px 0; text-align: left; display: flex; align-items: center; gap: 10px; font-size: 0.9rem; } .checkbox-container { margin: 15px 0; text-align: left; display: flex; align-items: center; gap: 10px; font-size: 0.9rem; }
.time-info { font-size: 0.85rem; color: #aaa; margin-bottom: 10px; font-weight: bold; }
#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; } .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; }
@@ -423,18 +427,36 @@ TEMPLATE = """
{% endfor %} {% endfor %}
</div> </div>
<!-- Tonie Auswahl Modal & Loading (unverändert) --> <!-- Tonie Auswahl Modal -->
<div id="tonieModal"> <div id="tonieModal">
<div class="modal-content"> <div class="modal-content">
<span class="modal-close" onclick="closeTonieModal()">&times;</span> <span class="modal-close" onclick="closeTonieModal()">&times;</span>
<h2 style="color: var(--primary); margin-top: 0;">Kreativ-Tonie auswählen</h2> <h2 id="modalTitle" style="color: var(--primary); margin-top: 0;">Kreativ-Tonie auswählen</h2>
<p id="modalBookTitle" style="color: #aaa; font-size: 0.9rem;"></p> <p id="modalBookTitle" style="color: #aaa; font-size: 0.9rem; margin-bottom: 15px;"></p>
<div class="checkbox-container">
<input type="checkbox" id="deleteAllChapters" style="transform: scale(1.2);"> <div id="tonieStep1">
<label for="deleteAllChapters">Vorhandene Kapitel auf dem Tonie löschen</label> <div class="tonie-list" id="tonieListContainer">
<p style="text-align: center; color: #aaa;">Lade Kreativ-Tonies...</p>
</div>
</div> </div>
<div class="tonie-list" id="tonieListContainer">
<p style="text-align: center; color: #aaa;">Lade Kreativ-Tonies...</p> <div id="tonieStep2" style="display: none;">
<p style="font-size: 0.9rem; font-weight: bold; margin-bottom: 5px;">Kapitel auswählen (max. 90 Minuten):</p>
<div class="time-info" id="timeInfo">Lade Spieldauer...</div>
<div class="checkbox-container" style="margin: 10px 0;">
<input type="checkbox" id="deleteAllChapters" checked style="transform: scale(1.2);">
<label for="deleteAllChapters" style="cursor: pointer;">Vorhandene Kapitel auf dem Tonie löschen</label>
</div>
<div class="chapter-list" id="chapterListContainer">
<p style="text-align: center; color: #aaa; padding: 10px;">Ermittle Kapitel-Längen...</p>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn" style="background: #333; color: #fff; flex: 1;" onclick="backToTonieList()">⬅️ Zurück</button>
<button class="btn btn-nav" style="flex: 1;" onclick="startUpload()">🚀 Hochladen</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -471,14 +493,20 @@ TEMPLATE = """
const downloadBtn = document.getElementById('downloadBtn'); const downloadBtn = document.getElementById('downloadBtn');
const tonieModal = document.getElementById('tonieModal'); const tonieModal = document.getElementById('tonieModal');
const modalTitle = document.getElementById('modalTitle');
const modalBookTitle = document.getElementById('modalBookTitle'); const modalBookTitle = document.getElementById('modalBookTitle');
const tonieListContainer = document.getElementById('tonieListContainer'); const tonieListContainer = document.getElementById('tonieListContainer');
const tonieStep1 = document.getElementById('tonieStep1');
const tonieStep2 = document.getElementById('tonieStep2');
const chapterListContainer = document.getElementById('chapterListContainer');
const timeInfo = document.getElementById('timeInfo');
const loadingOverlay = document.getElementById('loadingOverlay'); const loadingOverlay = document.getElementById('loadingOverlay');
const loadingText = document.getElementById('loadingText'); const loadingText = document.getElementById('loadingText');
let selectedBookNameForTonie = ''; let selectedBookNameForTonie = '';
let selectedTonieId = '';
// Kombinierte Such- und Filterlogik
function applyFilters() { function applyFilters() {
const term = searchInput ? searchInput.value.toLowerCase() : ''; const term = searchInput ? searchInput.value.toLowerCase() : '';
const selGenre = filterGenre ? filterGenre.value : ''; const selGenre = filterGenre ? filterGenre.value : '';
@@ -509,7 +537,6 @@ TEMPLATE = """
if (filterAge) filterAge.addEventListener('change', applyFilters); if (filterAge) filterAge.addEventListener('change', applyFilters);
if (filterFormat) filterFormat.addEventListener('change', applyFilters); if (filterFormat) filterFormat.addEventListener('change', applyFilters);
// Event-Listener für Karten (unverändert)
document.querySelectorAll('.card-clickable').forEach(el => { document.querySelectorAll('.card-clickable').forEach(el => {
el.addEventListener('click', () => { el.addEventListener('click', () => {
const name = el.getAttribute('data-name'); const name = el.getAttribute('data-name');
@@ -534,8 +561,11 @@ TEMPLATE = """
}); });
function openTonieModal(bookName) { function openTonieModal(bookName) {
modalTitle.innerText = "Kreativ-Tonie auswählen";
modalBookTitle.innerText = `Hörbuch: "${bookName}"`; modalBookTitle.innerText = `Hörbuch: "${bookName}"`;
tonieModal.style.display = 'flex'; tonieModal.style.display = 'flex';
tonieStep1.style.display = 'block';
tonieStep2.style.display = 'none';
tonieListContainer.innerHTML = '<p style="text-align: center; color: #aaa;">Lade Kreativ-Tonies...</p>'; tonieListContainer.innerHTML = '<p style="text-align: center; color: #aaa;">Lade Kreativ-Tonies...</p>';
fetch('/api/tonies') fetch('/api/tonies')
@@ -552,7 +582,7 @@ TEMPLATE = """
let html = ''; let html = '';
data.forEach(t => { data.forEach(t => {
html += ` html += `
<div class="tonie-item" onclick="uploadToTonie('${t.id}')"> <div class="tonie-item" onclick="selectTonie('${t.id}')">
<img src="${t.imageUrl || 'https://via.placeholder.com/50'}" alt="Tonie"> <img src="${t.imageUrl || 'https://via.placeholder.com/50'}" alt="Tonie">
<div> <div>
<div style="font-weight: bold;">${t.name}</div> <div style="font-weight: bold;">${t.name}</div>
@@ -568,18 +598,107 @@ TEMPLATE = """
}); });
} }
function formatDuration(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' : ''}${secs} Min.`;
}
function selectTonie(tonieId) {
selectedTonieId = tonieId;
modalTitle.innerText = "Kapitel auswählen";
tonieStep1.style.display = 'none';
tonieStep2.style.display = 'block';
chapterListContainer.innerHTML = '<p style="text-align: center; color: #aaa; padding: 10px;">Ermittle Kapitel-Längen...</p>';
timeInfo.innerText = "Lade Spieldauer...";
// Kapitel und Dauern erst jetzt asynchron vom Server holen
fetch(`/api/book-details?book_name=${encodeURIComponent(selectedBookNameForTonie)}`)
.then(res => res.json())
.then(data => {
if (data.error) {
chapterListContainer.innerHTML = `<p style="color: red; text-align: center;">${data.error}</p>`;
return;
}
let accumulatedSeconds = 0;
let html = '';
data.tracks.forEach((trackObj, index) => {
const durationSec = trackObj.duration || 0;
accumulatedSeconds += durationSec;
let isChecked = (accumulatedSeconds <= 5400 || index === 0) ? 'checked' : '';
let durationFormatted = durationSec > 0 ? formatDuration(durationSec) : '';
html += `
<div class="chapter-item">
<label>
<div style="display: flex; align-items: center; gap: 8px; overflow: hidden;">
<input type="checkbox" class="chapter-checkbox" value="${trackObj.name}" data-duration="${durationSec}" ${isChecked} onchange="updateTotalTime()">
<span style="word-break: break-all;">${index + 1}. ${trackObj.name}</span>
</div>
<span class="chapter-info-text">${durationFormatted}</span>
</label>
</div>
`;
});
chapterListContainer.innerHTML = html;
updateTotalTime();
})
.catch(err => {
chapterListContainer.innerHTML = '<p style="color: red; text-align: center;">Fehler beim Laden der Kapitel.</p>';
});
}
function backToTonieList() {
modalTitle.innerText = "Kreativ-Tonie auswählen";
tonieStep2.style.display = 'none';
tonieStep1.style.display = 'block';
}
function updateTotalTime() {
const checkboxes = document.querySelectorAll('.chapter-checkbox:checked');
let totalSeconds = 0;
checkboxes.forEach(cb => {
totalSeconds += parseFloat(cb.getAttribute('data-duration') || 0);
});
const totalMinutes = Math.round(totalSeconds / 60);
timeInfo.innerText = `Ausgewählt: ${checkboxes.length} Kapitel (${totalMinutes} Min. Gesamtlänge)`;
if (totalMinutes > 90) {
timeInfo.style.color = '#e50914';
timeInfo.innerText += " ⚠️ Achtung: Über 90 Minuten Limit!";
} else {
timeInfo.style.color = '#2ed573';
}
}
function closeTonieModal() { tonieModal.style.display = 'none'; } function closeTonieModal() { tonieModal.style.display = 'none'; }
function uploadToTonie(tonieId) { function startUpload() {
const checkboxes = document.querySelectorAll('.chapter-checkbox:checked');
const selectedTracks = Array.from(checkboxes).map(cb => cb.value);
if (selectedTracks.length === 0) {
alert('Bitte wähle mindestens ein Kapitel aus.');
return;
}
const deleteAll = document.getElementById('deleteAllChapters').checked; const deleteAll = document.getElementById('deleteAllChapters').checked;
closeTonieModal(); closeTonieModal();
loadingOverlay.style.display = 'flex'; loadingOverlay.style.display = 'flex';
loadingText.innerText = `Lade "${selectedBookNameForTonie}" auf den Kreativ-Tonie...`; loadingText.innerText = `Lade "${selectedBookNameForTonie}" (${selectedTracks.length} Kapitel) hoch...`;
fetch('/api/upload-to-tonie', { fetch('/api/upload-to-tonie', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ book_name: selectedBookNameForTonie, tonie_id: tonieId, delete_all: deleteAll }) body: JSON.stringify({
book_name: selectedBookNameForTonie,
tonie_id: selectedTonieId,
delete_all: deleteAll,
tracks: selectedTracks
})
}) })
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
@@ -725,9 +844,11 @@ def tonie_login():
session['tonie_token'] = token session['tonie_token'] = token
session['tonie_email'] = email session['tonie_email'] = email
logger.info(f"Toniecloud-Login erfolgreich für E-Mail: {email} (IP: {request.remote_addr})") logger.info(f"Toniecloud-Login erfolgreich für E-Mail: {email} (IP: {request.remote_addr})")
log_to_db("INFO", f"Toniecloud-Login erfolgreich für E-Mail: {email} (IP: {request.remote_addr})")
return redirect(url_for('index')) return redirect(url_for('index'))
else: else:
logger.warning(f"Toniecloud-Login fehlgeschlagen für E-Mail: {email} (IP: {request.remote_addr})") logger.warning(f"Toniecloud-Login fehlgeschlagen für E-Mail: {email} (IP: {request.remote_addr})")
log_to_db("WARNUNG", f"Toniecloud-Login fehlgeschlagen für E-Mail: {email} (IP: {request.remote_addr})")
error = "Login fehlgeschlagen. Bitte Zugangsdaten prüfen." error = "Login fehlgeschlagen. Bitte Zugangsdaten prüfen."
return render_template_string(TONIE_LOGIN_TEMPLATE, error=error) return render_template_string(TONIE_LOGIN_TEMPLATE, error=error)
@@ -740,7 +861,6 @@ def logout():
def index(): def index():
books = get_books() books = get_books()
# Eindeutige Listen für Dropdowns sammeln
all_genres = set() all_genres = set()
all_formats = set() all_formats = set()
all_agegroups = set() all_agegroups = set()
@@ -762,7 +882,6 @@ def index():
@app.route('/play/<path:book_name>') @app.route('/play/<path:book_name>')
def play_book(book_name): def play_book(book_name):
# 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=[]) 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>') @app.route('/cover/<path:book_name>')
@@ -799,6 +918,28 @@ def api_tonies():
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route('/api/book-details')
def api_book_details():
book_name = request.args.get('book_name')
if not book_name:
return jsonify({"error": "Kein Hörbuch angegeben."}), 400
book_path = os.path.join(BASE_DIR, book_name)
if not os.path.isdir(book_path):
return jsonify({"error": "Hörbuch-Ordner nicht gefunden."}), 404
try:
files = sorted([f for f in os.listdir(book_path) if f.lower().endswith(('.mp3', '.ogg'))])
tracks_data = []
for f in files:
f_path = os.path.join(book_path, f)
duration = get_track_duration(f_path)
tracks_data.append({"name": f, "duration": duration})
return jsonify({"tracks": tracks_data})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/upload-to-tonie', methods=['POST']) @app.route('/api/upload-to-tonie', methods=['POST'])
def api_upload_to_tonie(): def api_upload_to_tonie():
token = session.get('tonie_token') token = session.get('tonie_token')
@@ -809,12 +950,14 @@ def api_upload_to_tonie():
book_name = data.get('book_name') book_name = data.get('book_name')
tonie_id = data.get('tonie_id') tonie_id = data.get('tonie_id')
delete_all = data.get('delete_all', False) delete_all = data.get('delete_all', False)
selected_tracks = data.get('tracks', [])
user_email = session.get('tonie_email', 'Unbekannt') user_email = session.get('tonie_email', 'Unbekannt')
if not book_name or not tonie_id: if not book_name or not tonie_id or not selected_tracks:
return jsonify({"success": False, "error": "Fehlende Parameter."}), 400 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})") logger.info(f"Start Upload von '{book_name}' ({len(selected_tracks)} Kapitel) auf Tonie {tonie_id} durch Benutzer: {user_email} (IP: {request.remote_addr})")
log_to_db("INFO", f"Start Upload von '{book_name}' ({len(selected_tracks)} Kapitel) auf Tonie {tonie_id} durch Benutzer: {user_email} (IP: {request.remote_addr})")
try: try:
household_id = get_household_id(token) household_id = get_household_id(token)
@@ -823,11 +966,7 @@ def api_upload_to_tonie():
if not os.path.isdir(book_path): if not os.path.isdir(book_path):
return jsonify({"success": False, "error": "Hörbuch-Ordner nicht gefunden."}), 404 return jsonify({"success": False, "error": "Hörbuch-Ordner nicht gefunden."}), 404
files = sorted([f for f in os.listdir(book_path) if f.lower().endswith(('.mp3', '.ogg'))]) for index, track in enumerate(selected_tracks):
if not files:
return jsonify({"success": False, "error": "Keine Audiospuren im Hörbuch gefunden."}), 400
for index, track in enumerate(files):
file_path = os.path.join(book_path, track) file_path = os.path.join(book_path, track)
title = os.path.splitext(track)[0] title = os.path.splitext(track)[0]
should_delete = delete_all if index == 0 else False should_delete = delete_all if index == 0 else False
@@ -842,11 +981,40 @@ def api_upload_to_tonie():
) )
logger.info(f"Upload von '{book_name}' auf Tonie {tonie_id} durch {user_email} erfolgreich abgeschlossen.") logger.info(f"Upload von '{book_name}' auf Tonie {tonie_id} durch {user_email} erfolgreich abgeschlossen.")
log_to_db("INFO", f"Upload von '{book_name}' auf Tonie {tonie_id} durch {user_email} erfolgreich abgeschlossen.")
return jsonify({"success": True}) return jsonify({"success": True})
except Exception as e: except Exception as e:
logger.error(f"Fehler beim Upload von '{book_name}' durch {user_email}: {str(e)}") logger.error(f"Fehler beim Upload von '{book_name}' durch {user_email}: {str(e)}")
log_to_db("FEHLER", f"Fehler beim Upload von '{book_name}' durch {user_email}: {str(e)}")
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
def log_to_db(typ: str, logsatz: str):
try:
database = "toniecloud"
url = f"http://192.168.189.118:8000/{database}/execute"
# 1. SQL-Injection / Syntax-Fehler bei Quotes verhindern (' -> '')
safe_typ = str(typ).replace("'", "''")
safe_logsatz = str(logsatz).replace("'", "''")
payload = {
"aufrufer" : "tonieflix",
"sql": f"INSERT INTO flixlog (typ, logsatz) VALUES ('{safe_typ}', '{safe_logsatz}')"
}
# 2. Timeout (z.B. 2 Sekunden), damit das Hauptprogramm nicht hängen bleibt
response = requests.post(url, json=payload, timeout=2.0)
if response.status_code != 200:
print(f"⚠️ Log fehlgeschlagen (Code {response.status_code}): {response.text}")
except requests.exceptions.Timeout:
print("⚠️ Log-Timeout: Datenbank-Server antwortet nicht.")
except Exception as e:
print(f"⚠️ Log-Fehler: {str(e)}")
if __name__ == '__main__': if __name__ == '__main__':
log_to_db("INFO", "Starte Tonieflix")
app.run(host='0.0.0.0', port=5005) app.run(host='0.0.0.0', port=5005)