1069 lines
49 KiB
Python
1069 lines
49 KiB
Python
import os
|
|
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
|
|
from playwright.async_api import async_playwright
|
|
import requests
|
|
import mutagen
|
|
from mutagen.mp3 import MP3
|
|
from mutagen.oggvorbis import OggVorbis
|
|
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="*")
|
|
|
|
|
|
# ---------------------------------------------------------
|
|
# 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():
|
|
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:
|
|
if item.get('type') == 'table' and item.get('name') == 'toniebox':
|
|
for row in item.get('data', []):
|
|
title = row.get('title')
|
|
if not title:
|
|
continue
|
|
|
|
title_key = title.lower().strip()
|
|
if title_key not in metadata:
|
|
metadata[title_key] = {'genres': set(), 'formats': set(), 'agegroups': set()}
|
|
|
|
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 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 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}")
|
|
log_to_db("WARNUNG", f"Fehler beim Laden der JSON-Datenbank: {e}")
|
|
|
|
|
|
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
|
|
# ---------------------------------------------------------
|
|
def get_tonie_api_token(username: str, password: str) -> str | None:
|
|
token_url = "https://login.tonies.com/auth/realms/tonies/protocol/openid-connect/token"
|
|
payload = {
|
|
"grant_type": "password",
|
|
"client_id": "my-tonies",
|
|
"username": username,
|
|
"password": password,
|
|
"scope": "openid"
|
|
}
|
|
headers = {
|
|
"Content-Type": "application/x-www-form-urlencoded"
|
|
}
|
|
|
|
try:
|
|
resp = requests.post(token_url, data=payload, headers=headers, timeout=10)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
return data.get("access_token")
|
|
else:
|
|
logger.error(f"Fehler bei Token-Anfrage: {resp.status_code} - {resp.text}")
|
|
log_to_db("FEHLER", f"Fehler bei Token-Anfrage: {resp.status_code} - {resp.text}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Exception beim Token-Abruf: {e}")
|
|
log_to_db("FEHLER", f"Exception beim Token-Abruf: {e}")
|
|
return None
|
|
|
|
async def get_tonie_api_token_playwright(username: str, password: str) -> str | None:
|
|
async with async_playwright() as p:
|
|
browser = await p.chromium.launch(headless=True)
|
|
context = await browser.new_context(viewport={'width': 1280, 'height': 800})
|
|
page = await context.new_page()
|
|
|
|
login_url = (
|
|
"https://login.tonies.com/auth/realms/tonies/protocol/openid-connect/auth?"
|
|
"client_id=my-tonies&redirect_uri=https%3A%2F%2Fmy.tonies.com%2Flogin%3Fredirect%3D%252F&"
|
|
"state=24115267-a4f0-4033-a2d4-bef68c83b07d&response_mode=fragment&"
|
|
"response_type=code&scope=openid&nonce=ee64d559-2b1c-4f4d-a01f-40d84c4dced1&"
|
|
"ui_locales=de&code_challenge=_lFACYXyUjTQEnb3JiqUWQ7xu4zA50xMNyXhhp3uUe8&"
|
|
"code_challenge_method=S256"
|
|
)
|
|
|
|
try:
|
|
await page.goto(login_url)
|
|
try:
|
|
await page.wait_for_selector("[data-testid='email-toggle']", timeout=5000)
|
|
await page.click("[data-testid='email-toggle']")
|
|
except Exception:
|
|
pass
|
|
|
|
await page.wait_for_timeout(1000)
|
|
await page.wait_for_selector("input[id='username'], input[name='username']", timeout=5000)
|
|
|
|
await page.type("input[id='username'], input[name='username']", username, delay=30)
|
|
await page.type("input[id='password'], input[name='password']", password, delay=30)
|
|
await page.click("input[type='submit'], button[type='submit']")
|
|
|
|
await page.wait_for_timeout(5000)
|
|
access_token = await page.evaluate("() => window.localStorage.getItem('authorization')")
|
|
await browser.close()
|
|
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
|
|
|
|
|
|
def get_household_id(access_token: str) -> str:
|
|
headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json", "User-Agent": "Mozilla/5.0"}
|
|
resp = requests.get("https://api.tonie.cloud/v2/households", headers=headers)
|
|
if resp.status_code != 200:
|
|
raise Exception(f"Fehler beim Laden der Haushalte: {resp.status_code} - {resp.text}")
|
|
households = resp.json()
|
|
household_list = households if isinstance(households, list) else households.get("households", [])
|
|
if not household_list:
|
|
raise Exception("Kein Haushalt gefunden.")
|
|
return household_list[0]["id"]
|
|
|
|
|
|
def get_creative_tonies(access_token: str, household_id: str) -> list[dict]:
|
|
headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json", "User-Agent": "Mozilla/5.0"}
|
|
resp = requests.get(f"https://api.tonie.cloud/v2/households/{household_id}/creativetonies", headers=headers)
|
|
if resp.status_code != 200:
|
|
raise Exception(f"Fehler beim Laden der Kreativ-Tonies: {resp.status_code} - {resp.text}")
|
|
raw_tonies = resp.json()
|
|
if isinstance(raw_tonies, dict):
|
|
raw_tonies = raw_tonies.get("creativeTonies", raw_tonies.get("items", []))
|
|
return [{"id": t.get("id"), "name": t.get("name"), "imageUrl": t.get("imageUrl")} for t in raw_tonies]
|
|
|
|
|
|
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"}
|
|
|
|
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}")
|
|
|
|
amazon_data = upload_req_resp.json()
|
|
s3_request = amazon_data.get("request", {})
|
|
fields = s3_request.get("fields", {})
|
|
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/")
|
|
|
|
if not Path(file_path).exists():
|
|
raise FileNotFoundError(f"Datei {file_path} nicht gefunden.")
|
|
|
|
multipart_fields = {
|
|
"key": fields.get("key"),
|
|
"x-amz-algorithm": fields.get("x-amz-algorithm"),
|
|
"x-amz-credential": fields.get("x-amz-credential"),
|
|
"x-amz-date": fields.get("x-amz-date"),
|
|
"policy": fields.get("policy"),
|
|
"x-amz-signature": fields.get("x-amz-signature"),
|
|
"x-amz-security-token": fields.get("x-amz-security-token"),
|
|
}
|
|
|
|
with open(file_path, "rb") as f:
|
|
files = {"file": (fields.get("key"), f, "audio/mpeg")}
|
|
s3_resp = requests.post(s3_url, data=multipart_fields, files=files)
|
|
|
|
if s3_resp.status_code not in (200, 204):
|
|
raise Exception(f"S3-Upload fehlgeschlagen: {s3_resp.text}")
|
|
|
|
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}")
|
|
|
|
tonie_data = tonie_detail_resp.json()
|
|
|
|
cleaned_chapters = []
|
|
if not delete_all_chapters:
|
|
for ch in tonie_data.get("chapters", []):
|
|
c_id = ch.get("id")
|
|
c_file = ch.get("file")
|
|
c_title = ch.get("title", "")
|
|
if isinstance(c_file, dict):
|
|
c_file = c_file.get("id") or c_file.get("file")
|
|
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(fields.get("key")),
|
|
"file": str(file_id),
|
|
"title": str(title)
|
|
})
|
|
|
|
update_payload = {
|
|
"name": tonie_data.get("name"),
|
|
"imageUrl": tonie_data.get("imageUrl"),
|
|
"chapters": cleaned_chapters
|
|
}
|
|
|
|
update_resp = requests.put(
|
|
f"https://api.tonie.cloud/v2/households/{household_id}/creativetonies/{tonie_id}",
|
|
json=update_payload,
|
|
headers=headers
|
|
)
|
|
|
|
if update_resp.status_code not in (200, 204):
|
|
raise Exception(f"Fehler beim Speichern der Kapitel: {update_resp.text}")
|
|
|
|
|
|
# ---------------------------------------------------------
|
|
# Bibliothek abrufen (Nur Grundstruktur, superschnell)
|
|
# ---------------------------------------------------------
|
|
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:
|
|
item_path = os.path.join(BASE_DIR, item)
|
|
if os.path.isdir(item_path):
|
|
files = os.listdir(item_path)
|
|
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 track_files:
|
|
item_key = item.lower().strip()
|
|
meta = metadata_lookup.get(item_key, {'genres': [], 'formats': [], 'agegroups': []})
|
|
|
|
books.append({
|
|
'name': item,
|
|
'tracks': track_files,
|
|
'has_cover': has_cover,
|
|
'genres': meta['genres'],
|
|
'formats': meta['formats'],
|
|
'agegroups': meta['agegroups']
|
|
})
|
|
return books
|
|
|
|
|
|
# ---------------------------------------------------------
|
|
# HTML & JS Template (Frontend)
|
|
# ---------------------------------------------------------
|
|
TEMPLATE = """
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Tonieflix</title>
|
|
<style>
|
|
:root { --primary: #e50914; --bg: #141414; --card: #1f1f1f; --text: #ffffff; }
|
|
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: 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(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); }
|
|
.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); }
|
|
|
|
@media (max-width: 600px) {
|
|
body { padding: 10px; padding-bottom: 160px; }
|
|
h1 { font-size: 2.2rem; }
|
|
.grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; }
|
|
.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; }
|
|
}
|
|
|
|
/* Player Bar */
|
|
.player-bar {
|
|
position: fixed; bottom: 20px; left: 15px; right: 15px;
|
|
background: #181818; border: 2px solid var(--primary); border-radius: 16px;
|
|
padding: 15px 20px; box-shadow: 0 -4px 20px rgba(0,0,0,0.8);
|
|
display: flex; flex-direction: column; align-items: center; gap: 12px; z-index: 1000;
|
|
}
|
|
.player-info { font-weight: bold; color: var(--primary); font-size: 1.1rem; text-align: center; }
|
|
.player-controls { display: flex; align-items: center; gap: 15px; width: 100%; max-width: 800px; justify-content: center; flex-wrap: wrap; }
|
|
audio { flex-grow: 1; min-width: 250px; outline: none; }
|
|
|
|
.btn { border: none; padding: 10px 15px; border-radius: 25px; cursor: pointer; font-size: 1rem; font-weight: bold; transition: opacity 0.2s; }
|
|
.btn:hover { opacity: 0.8; }
|
|
.btn:disabled { background: #444; color: #777; cursor: not-allowed; opacity: 1; }
|
|
.btn-nav { background: var(--primary); color: white; text-decoration: none; display: inline-block; }
|
|
.btn-dl { background: #2ed573; color: white; text-decoration: none; display: inline-block; }
|
|
|
|
.logout { position: absolute; top: 20px; right: 20px; color: #aaa; text-decoration: none; font-weight: bold; }
|
|
.logout:hover { color: #fff; }
|
|
|
|
/* 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: 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: 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; }
|
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<a href="{{ url_for('logout') }}" class="logout">Abmelden</a>
|
|
<h1>🎬 Tonieflix</h1>
|
|
|
|
<div class="top-controls">
|
|
{% if single_mode %}
|
|
<a href="{{ url_for('index') }}" class="btn btn-nav" style="font-size: 1.1rem; padding: 12px 25px;">📚 Gesamte Bibliothek laden</a>
|
|
{% else %}
|
|
<input type="text" id="searchInput" placeholder="Hörbuch suchen...">
|
|
<a href="{{ url_for('tonie_login') }}" class="btn" style="background: #333; color: #fff; border: 1px solid #444;">🦊 Tonie-Login</a>
|
|
{% 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() }}"
|
|
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">
|
|
{% else %}
|
|
<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>
|
|
<button class="btn-tonie" data-name="{{ book.name }}">🦊 Auf Tonie</button>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<p style="text-align: center; width: 100%; color: #aaa;">Keine Hörbücher gefunden.</p>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- Tonie Auswahl Modal -->
|
|
<div id="tonieModal">
|
|
<div class="modal-content">
|
|
<span class="modal-close" onclick="closeTonieModal()">×</span>
|
|
<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; margin-bottom: 15px;"></p>
|
|
|
|
<div id="tonieStep1">
|
|
<div class="tonie-list" id="tonieListContainer">
|
|
<p style="text-align: center; color: #aaa;">Lade Kreativ-Tonies...</p>
|
|
</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 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>
|
|
|
|
<div class="player-bar" id="playerBar" style="display: none;">
|
|
<div class="player-info" id="playerInfo">Wird geladen...</div>
|
|
<div class="player-controls">
|
|
<button class="btn btn-nav" id="prevBtn">⏮</button>
|
|
<audio id="audioPlayer" controls></audio>
|
|
<button class="btn btn-nav" id="nextBtn">⏭</button>
|
|
<a href="#" class="btn btn-dl" id="downloadBtn">⬇️ ZIP</a>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const audioPlayer = document.getElementById('audioPlayer');
|
|
const playerBar = document.getElementById('playerBar');
|
|
const playerInfo = document.getElementById('playerInfo');
|
|
|
|
const searchInput = document.getElementById('searchInput');
|
|
const filterGenre = document.getElementById('filterGenre');
|
|
const filterAge = document.getElementById('filterAge');
|
|
const filterFormat = document.getElementById('filterFormat');
|
|
const cards = document.querySelectorAll('.card');
|
|
|
|
const prevBtn = document.getElementById('prevBtn');
|
|
const nextBtn = document.getElementById('nextBtn');
|
|
const downloadBtn = document.getElementById('downloadBtn');
|
|
|
|
const tonieModal = document.getElementById('tonieModal');
|
|
const modalTitle = document.getElementById('modalTitle');
|
|
const modalBookTitle = document.getElementById('modalBookTitle');
|
|
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 loadingText = document.getElementById('loadingText');
|
|
|
|
let selectedBookNameForTonie = '';
|
|
let selectedTonieId = '';
|
|
|
|
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';
|
|
}
|
|
});
|
|
}
|
|
|
|
if (searchInput) searchInput.addEventListener('input', applyFilters);
|
|
if (filterGenre) filterGenre.addEventListener('change', applyFilters);
|
|
if (filterAge) filterAge.addEventListener('change', applyFilters);
|
|
if (filterFormat) filterFormat.addEventListener('change', applyFilters);
|
|
|
|
document.querySelectorAll('.card-clickable').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
const name = el.getAttribute('data-name');
|
|
const tracks = JSON.parse(el.getAttribute('data-tracks'));
|
|
startBook(name, tracks);
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.btn-direct').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
copyDirectLink(btn.getAttribute('data-name'));
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.btn-tonie').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
selectedBookNameForTonie = btn.getAttribute('data-name');
|
|
openTonieModal(selectedBookNameForTonie);
|
|
});
|
|
});
|
|
|
|
function openTonieModal(bookName) {
|
|
modalTitle.innerText = "Kreativ-Tonie auswählen";
|
|
modalBookTitle.innerText = `Hörbuch: "${bookName}"`;
|
|
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>';
|
|
|
|
fetch('/api/tonies')
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
tonieListContainer.innerHTML = `<p style="color: var(--primary); text-align: center;">${data.error} <br><a href="/tonie-login" style="color: #fff;">Hier einloggen</a></p>`;
|
|
return;
|
|
}
|
|
if (data.length === 0) {
|
|
tonieListContainer.innerHTML = '<p style="text-align: center; color: #aaa;">Keine Kreativ-Tonies gefunden.</p>';
|
|
return;
|
|
}
|
|
let html = '';
|
|
data.forEach(t => {
|
|
html += `
|
|
<div class="tonie-item" onclick="selectTonie('${t.id}')">
|
|
<img src="${t.imageUrl || 'https://via.placeholder.com/50'}" alt="Tonie">
|
|
<div>
|
|
<div style="font-weight: bold;">${t.name}</div>
|
|
<div style="font-size: 0.75rem; color: #888;">ID: ${t.id}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
tonieListContainer.innerHTML = html;
|
|
})
|
|
.catch(err => {
|
|
tonieListContainer.innerHTML = '<p style="color: red; text-align: center;">Fehler beim Laden der Tonies.</p>';
|
|
});
|
|
}
|
|
|
|
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 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;
|
|
closeTonieModal();
|
|
loadingOverlay.style.display = 'flex';
|
|
loadingText.innerText = `Lade "${selectedBookNameForTonie}" (${selectedTracks.length} Kapitel) hoch...`;
|
|
|
|
fetch('/api/upload-to-tonie', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
book_name: selectedBookNameForTonie,
|
|
tonie_id: selectedTonieId,
|
|
delete_all: deleteAll,
|
|
tracks: selectedTracks
|
|
})
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
loadingOverlay.style.display = 'none';
|
|
if (data.success) {
|
|
alert('Hörbuch erfolgreich auf den Kreativ-Tonie geladen! 🎉');
|
|
} else {
|
|
alert('Fehler beim Upload: ' + (data.error || 'Unbekannter Fehler'));
|
|
}
|
|
})
|
|
.catch(err => {
|
|
loadingOverlay.style.display = 'none';
|
|
alert('Netzwerkfehler beim Upload.');
|
|
});
|
|
}
|
|
|
|
prevBtn.addEventListener('click', () => { if (currentBookState.trackIndex > 0) { currentBookState.trackIndex--; loadTrackInfo(true); } });
|
|
nextBtn.addEventListener('click', () => { if (currentBookState.trackIndex < currentBookState.tracks.length - 1) { currentBookState.trackIndex++; loadTrackInfo(true); } });
|
|
|
|
function copyDirectLink(bookName) {
|
|
const url = window.location.origin + '/play/' + encodeURIComponent(bookName);
|
|
navigator.clipboard?.writeText(url).then(() => alert('Direktlink kopiert!')).catch(() => prompt('Direktlink:', url));
|
|
}
|
|
|
|
let currentBookState = { bookName: '', tracks: [], trackIndex: 0 };
|
|
function startBook(bookName, tracks) {
|
|
if (!tracks || tracks.length === 0) return;
|
|
currentBookState = { bookName: bookName, tracks: tracks, trackIndex: 0 };
|
|
playerBar.style.display = 'flex';
|
|
loadTrackInfo(true);
|
|
}
|
|
|
|
function loadTrackInfo(autoPlay) {
|
|
const track = currentBookState.tracks[currentBookState.trackIndex];
|
|
audioPlayer.src = `/stream/${encodeURIComponent(currentBookState.bookName)}/${encodeURIComponent(track)}`;
|
|
playerInfo.innerText = `${currentBookState.bookName} (Kapitel ${currentBookState.trackIndex + 1} von ${currentBookState.tracks.length})`;
|
|
prevBtn.disabled = currentBookState.trackIndex === 0;
|
|
nextBtn.disabled = currentBookState.trackIndex === currentBookState.tracks.length - 1;
|
|
downloadBtn.href = `/download/${encodeURIComponent(currentBookState.bookName)}`;
|
|
if (autoPlay) audioPlayer.play().catch(e => console.warn(e));
|
|
}
|
|
|
|
audioPlayer.addEventListener('ended', () => {
|
|
if (currentBookState.trackIndex < currentBookState.tracks.length - 1) {
|
|
currentBookState.trackIndex++;
|
|
loadTrackInfo(true);
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
LOGIN_TEMPLATE = """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Login - Tonieflix</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<style>
|
|
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background: #141414; color: #fff; margin: 0; }
|
|
.login-box { background: #1f1f1f; padding: 40px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.8); text-align: center; width: 100%; max-width: 320px; }
|
|
input { padding: 12px; margin: 10px 0; border: 1px solid #333; background: #2b2b2b; color: #fff; border-radius: 5px; width: 100%; box-sizing: border-box;}
|
|
button { background: #e50914; color: white; border: none; padding: 12px 20px; border-radius: 5px; cursor: pointer; width: 100%; font-size: 1rem; font-weight: bold; }
|
|
button:hover { opacity: 0.9; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="login-box">
|
|
<h2 style="color: #e50914; margin-top: 0;">🎬 Tonieflix</h2>
|
|
<form method="POST">
|
|
<input type="password" name="password" placeholder="App-Passwort" required autofocus>
|
|
<button type="submit">Eintreten</button>
|
|
</form>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
TONIE_LOGIN_TEMPLATE = """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Tonie-Login - Tonieflix</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<style>
|
|
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background: #141414; color: #fff; margin: 0; }
|
|
.login-box { background: #1f1f1f; padding: 40px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.8); text-align: center; width: 100%; max-width: 350px; }
|
|
input { padding: 12px; margin: 10px 0; border: 1px solid #333; background: #2b2b2b; color: #fff; border-radius: 5px; width: 100%; box-sizing: border-box;}
|
|
button { background: #e50914; color: white; border: none; padding: 12px 20px; border-radius: 5px; cursor: pointer; width: 100%; font-size: 1rem; font-weight: bold; }
|
|
button:hover { opacity: 0.9; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="login-box">
|
|
<h2 style="color: #e50914; margin-top: 0;">🦊 Tonies Account</h2>
|
|
<p style="font-size: 0.85rem; color: #aaa;">Gib deine Zugangsdaten für my.tonies.com ein:</p>
|
|
<form method="POST">
|
|
<input type="email" name="tonie_email" placeholder="E-Mail-Adresse" required>
|
|
<input type="password" name="tonie_password" placeholder="Passwort" required>
|
|
<button type="submit">Token generieren & Einloggen</button>
|
|
</form>
|
|
{% if error %}
|
|
<p style="color: var(--primary); font-size: 0.9rem; margin-top: 15px;">{{ error }}</p>
|
|
{% endif %}
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
# ---------------------------------------------------------
|
|
# Flask Routen
|
|
# ---------------------------------------------------------
|
|
@app.before_request
|
|
def require_login():
|
|
# CORS Preflight oder öffentliche API-Routen ignorieren
|
|
if request.method == 'OPTIONS' or request.path.startswith('/api/books'):
|
|
return None
|
|
|
|
# Passwort über URL-Parameter abfangen (?pw=... oder ?password=...)
|
|
url_pw = request.args.get('pw') or request.args.get('password')
|
|
if url_pw and url_pw == PASSWORD:
|
|
session['logged_in'] = True
|
|
|
|
# Sauberer Redirect ohne Passwort-Parameter in der URL-Historie:
|
|
args = request.args.copy()
|
|
args.pop('pw', None)
|
|
args.pop('password', None)
|
|
clean_url = request.path + ('?' + args.to_encoded_string() if args else '')
|
|
return redirect(clean_url)
|
|
|
|
# Prüfen, ob eingeloggt oder auf Login/Statics unterwegs
|
|
if request.endpoint not in ['login', 'static'] and not session.get('logged_in'):
|
|
return redirect(url_for('login', next=request.full_path if request.full_path != '/' else None))
|
|
|
|
@app.route('/login', methods=['GET', 'POST'])
|
|
def login():
|
|
next_url = request.args.get('next') or url_for('index')
|
|
|
|
# Schneller Login per GET-Parameter direkt auf /login
|
|
url_pw = request.args.get('pw') or request.args.get('password')
|
|
if url_pw == PASSWORD:
|
|
session['logged_in'] = True
|
|
return redirect(next_url)
|
|
|
|
if request.method == 'POST':
|
|
if request.form.get('password') == PASSWORD:
|
|
session['logged_in'] = True
|
|
return redirect(next_url)
|
|
else:
|
|
return "Falsches Passwort!", 403
|
|
|
|
return render_template_string(LOGIN_TEMPLATE)
|
|
|
|
@app.route('/tonie-login', methods=['GET', 'POST'])
|
|
def tonie_login():
|
|
error = None
|
|
if request.method == 'POST':
|
|
email = request.form.get('tonie_email')
|
|
pwd = request.form.get('tonie_password')
|
|
|
|
token = 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})")
|
|
log_to_db("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})")
|
|
log_to_db("WARNUNG", 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)
|
|
|
|
@app.route('/logout')
|
|
def logout():
|
|
session.clear()
|
|
return redirect(url_for('login'))
|
|
|
|
@app.route('/')
|
|
def index():
|
|
books = get_books()
|
|
|
|
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, all_genres=[], all_formats=[], all_agegroups=[])
|
|
|
|
@app.route('/cover/<path:book_name>')
|
|
def serve_cover(book_name):
|
|
return send_from_directory(os.path.join(BASE_DIR, book_name), 'cover.jpg')
|
|
|
|
@app.route('/stream/<path:book_name>/<path:track_name>')
|
|
def serve_audio(book_name, track_name):
|
|
return send_from_directory(os.path.join(BASE_DIR, book_name), track_name)
|
|
|
|
@app.route('/download/<path:book_name>')
|
|
def download_book(book_name):
|
|
book_path = os.path.join(BASE_DIR, book_name)
|
|
if not os.path.isdir(book_path):
|
|
return "Hörbuch nicht gefunden", 404
|
|
memory_file = io.BytesIO()
|
|
with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_STORED) as zf:
|
|
for root, dirs, files in os.walk(book_path):
|
|
for file in files:
|
|
zf.write(os.path.join(root, file), arcname=file)
|
|
memory_file.seek(0)
|
|
return send_file(memory_file, download_name=f"{book_name}.zip", as_attachment=True, mimetype='application/zip')
|
|
|
|
# --- API Endpunkte für Tonies ---
|
|
@app.route('/api/tonies')
|
|
def api_tonies():
|
|
token = session.get('tonie_token')
|
|
if not token:
|
|
return jsonify({"error": "Nicht bei Tonies eingeloggt."}), 401
|
|
try:
|
|
household_id = get_household_id(token)
|
|
tonies = get_creative_tonies(token, household_id)
|
|
return jsonify(tonies)
|
|
except Exception as e:
|
|
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'])
|
|
def api_upload_to_tonie():
|
|
token = session.get('tonie_token')
|
|
if not token:
|
|
return jsonify({"success": False, "error": "Nicht eingeloggt."}), 401
|
|
|
|
data = request.json
|
|
book_name = data.get('book_name')
|
|
tonie_id = data.get('tonie_id')
|
|
delete_all = data.get('delete_all', False)
|
|
selected_tracks = data.get('tracks', [])
|
|
user_email = session.get('tonie_email', 'Unbekannt')
|
|
|
|
if not book_name or not tonie_id or not selected_tracks:
|
|
return jsonify({"success": False, "error": "Fehlende Parameter."}), 400
|
|
|
|
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:
|
|
household_id = get_household_id(token)
|
|
book_path = os.path.join(BASE_DIR, book_name)
|
|
|
|
if not os.path.isdir(book_path):
|
|
return jsonify({"success": False, "error": "Hörbuch-Ordner nicht gefunden."}), 404
|
|
|
|
for index, track in enumerate(selected_tracks):
|
|
file_path = os.path.join(book_path, track)
|
|
title = os.path.splitext(track)[0]
|
|
should_delete = delete_all if index == 0 else False
|
|
|
|
upload_audio_to_creative_tonie(
|
|
access_token=token,
|
|
household_id=household_id,
|
|
tonie_id=tonie_id,
|
|
file_path=file_path,
|
|
title=title,
|
|
delete_all_chapters=should_delete
|
|
)
|
|
|
|
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})
|
|
except Exception as 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
|
|
|
|
|
|
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__':
|
|
log_to_db("INFO", "Starte Tonieflix")
|
|
app.run(host='0.0.0.0', port=5005)
|