diff --git a/app.py b/app.py index 0c49aef..0cb6dc8 100644 --- a/app.py +++ b/app.py @@ -9,6 +9,9 @@ from flask import Flask, render_template_string, request, session, redirect, url from flask_cors import CORS from playwright.async_api import async_playwright import requests +import mutagen +from mutagen.mp3 import MP3 +from mutagen.oggvorbis import OggVorbis import config # Konfiguration @@ -34,11 +37,25 @@ app.config['SECRET_KEY'] = config.SECRET_KEY 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 # --------------------------------------------------------- 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: @@ -46,45 +63,35 @@ def load_metadata(): 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) + 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) + 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) - + if clean_age: metadata[title_key]['agegroups'].add(clean_age) except Exception as 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(): val['genres'] = list(val['genres']) val['formats'] = list(val['formats']) @@ -132,6 +139,7 @@ async def get_tonie_api_token(username: str, password: str) -> str | None: return access_token except Exception as e: logger.error(f"Playwright Login-Fehler: {e}") + log_to_db("FEHLER", f"Playwright Login-Fehler: {e}") await browser.close() 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): 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) if upload_req_resp.status_code != 200: 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") 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(): 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): 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) if tonie_detail_resp.status_code != 200: 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": cleaned_chapters.append({"id": str(c_id), "file": str(c_file), "title": str(c_title)}) - # Neues Kapitel anhängen cleaned_chapters.append({ "id": str(fields.get("key")), "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): books = [] @@ -248,17 +252,16 @@ def get_books(single_book=None): 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'))]) + 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) - if tracks: - # Match der Metadaten basierend auf dem Ordnernamen + if track_files: item_key = item.lower().strip() meta = metadata_lookup.get(item_key, {'genres': [], 'formats': [], 'agegroups': []}) books.append({ 'name': item, - 'tracks': tracks, + 'tracks': track_files, 'has_cover': has_cover, 'genres': meta['genres'], '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: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; } .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); } @@ -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: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) { body { padding: 10px; padding-bottom: 160px; } 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; } - - /* Schriften und Abstände kompakter machen */ .card .title { font-size: 0.85rem; padding: 8px 5px 2px 5px; } .card .meta-info { font-size: 0.65rem; padding: 0 5px 5px 5px; } .card-actions { padding: 8px; gap: 4px; } .btn-direct, .btn-tonie { font-size: 0.75rem; padding: 4px 8px; } - .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: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; } - .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: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:hover { background: #3a3a3a; border: 1px solid var(--primary); } .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; } + .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; } .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 %} - +