test
This commit is contained in:
1 parent
d75cb0bf76
commit
160dabc59d
1 file changed
+225
-57
@@ -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,20 +427,38 @@ 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()">×</span>
|
<span class="modal-close" onclick="closeTonieModal()">×</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>
|
|
||||||
<div class="tonie-list" id="tonieListContainer">
|
<div class="tonie-list" id="tonieListContainer">
|
||||||
<p style="text-align: center; color: #aaa;">Lade Kreativ-Tonies...</p>
|
<p style="text-align: center; color: #aaa;">Lade Kreativ-Tonies...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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 id="loadingOverlay">
|
<div id="loadingOverlay">
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user