Umbenennung: Audiofy heißt jetzt Playwish

Name, Seitentitel, Dienste (playwish-web, playwish-controller), Dienst-Benutzer
(playwish), Pfade (/opt/playwish, /usr/local/src/playwish), Vorlagen und
Repository-Adresse. „Audiofy“ war bereits von mehreren Produkten belegt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 20:27:59 +01:00
co-authored by Claude Opus 5.5
parent ca8db176ac
commit fc15f2b933
44 changed files with 218 additions and 218 deletions
+1 -1
View File
@@ -1 +1 @@
"""Audiofy-Web – Website, Admin-Bereich und Player-API (läuft auf dem Server)."""
"""Playwish-Web – Website, Admin-Bereich und Player-API (läuft auf dem Server)."""
+3 -3
View File
@@ -1,4 +1,4 @@
"""Audiofy-Web – Admin-Bereich (/admin): der Besitzer steuert alles, auch vom Handy.
"""Playwish-Web – Admin-Bereich (/admin): der Besitzer steuert alles, auch vom Handy.
Zugang:
- Passwort als argon2-Hash in der .env (ADMIN_PASSWORD_HASH, gesetzt per verwaltung.py)
@@ -36,7 +36,7 @@ from .spotify import Spotify, SpotifyFehler, spotify
router = APIRouter()
ADMIN_COOKIE = "audiofy_admin"
ADMIN_COOKIE = "playwish_admin"
SITZUNG_TAGE = 14
login_drossel = Drossel(5, 15 * 60)
_hasher = PasswordHasher()
@@ -390,7 +390,7 @@ def zugang_qr(request: Request, format: Literal["svg", "png"] = "svg",
if format == "png":
qr.save(puffer, kind="png", scale=16, border=3)
return Response(puffer.getvalue(), media_type="image/png", headers={
"Content-Disposition": 'attachment; filename="audiofy-einladung.png"', "Cache-Control": "no-store"})
"Content-Disposition": 'attachment; filename="playwish-einladung.png"', "Cache-Control": "no-store"})
qr.save(puffer, kind="svg", scale=8, border=2, xmldecl=False, svgns=True)
return Response(puffer.getvalue(), media_type="image/svg+xml", headers={"Cache-Control": "no-store"})
+4 -4
View File
@@ -1,6 +1,6 @@
"""Audiofy-Web – Konfiguration aus der .env.
"""Playwish-Web – Konfiguration aus der .env.
Pfad über die Umgebungsvariable AUDIOFY_ENV, Standard /opt/audiofy/.env.
Pfad über die Umgebungsvariable PLAYWISH_ENV, Standard /opt/playwish/.env.
Geheimnisse stehen nur dort (Rechte 600), nie im Code oder in Logs.
"""
@@ -51,11 +51,11 @@ def env_wert_setzen(pfad: Path, key: str, wert: str) -> None:
@lru_cache
def konfig() -> Konfig:
pfad = Path(os.environ.get("AUDIOFY_ENV", "/opt/audiofy/.env"))
pfad = Path(os.environ.get("PLAYWISH_ENV", "/opt/playwish/.env"))
werte = env_lesen(pfad) if pfad.is_file() else {}
return Konfig(
env_pfad=pfad,
db_pfad=Path(werte.get("DB_PATH") or "/opt/audiofy/data/audiofy.db"),
db_pfad=Path(werte.get("DB_PATH") or "/opt/playwish/data/playwish.db"),
player_api_token=werte.get("PLAYER_API_TOKEN") or werte.get("PI_API_TOKEN", ""), # alter Name
spotify_client_id=werte.get("SPOTIFY_CLIENT_ID", ""),
spotify_client_secret=werte.get("SPOTIFY_CLIENT_SECRET", ""),
+1 -1
View File
@@ -1,4 +1,4 @@
"""Audiofy-Web – SQLite-Datenbank (audiofy.db): Schema, Einstellungen, Hilfsfunktionen.
"""Playwish-Web – SQLite-Datenbank (playwish.db): Schema, Einstellungen, Hilfsfunktionen.
Zeitstempel sind UTC im ISO-Format (lexikografisch vergleichbar).
"""
+4 -4
View File
@@ -1,4 +1,4 @@
"""Audiofy-Web – Gastseite: Suche, Wünschen, "Läuft gerade" und Warteschlange.
"""Playwish-Web – Gastseite: Suche, Wünschen, "Läuft gerade" und Warteschlange.
Zugang nur mit Einladung: Der Link /z/<token> (auch als QR-Code im Admin-Bereich)
setzt ein Zugangs-Cookie. Ohne gültigen Zugang gibt es nur eine Hinweisseite, die
@@ -42,8 +42,8 @@ from .spotify import Spotify, SpotifyFehler, spotify
router = APIRouter()
STATIC = Path(__file__).parent / "static"
GAST_COOKIE = "audiofy_gast"
ZUGANG_COOKIE = "audiofy_zugang"
GAST_COOKIE = "playwish_gast"
ZUGANG_COOKIE = "playwish_zugang"
ZUGANG_TAGE = 30
GAST_RE = re.compile(r"^[A-Za-z0-9_-]{22}$")
PLAYER_OFFLINE_S = 30 # ohne Lebenszeichen gilt der Player als offline
@@ -92,7 +92,7 @@ def gast_cookie_setzen(response: Response, gid: str) -> None:
def _geheimnis() -> bytes:
return (konfig().session_secret or "audiofy").encode()
return (konfig().session_secret or "playwish").encode()
def zugangs_wert(token: str) -> str:
+2 -2
View File
@@ -1,4 +1,4 @@
"""Audiofy-Web – FastAPI-Anwendung.
"""Playwish-Web – FastAPI-Anwendung.
Start (Server): uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 1 --no-access-log
Caddy terminiert HTTPS und leitet an 127.0.0.1:8000 weiter.
@@ -26,7 +26,7 @@ async def lifespan(app: FastAPI):
# Keine automatische API-Doku öffentlich ausliefern
app = FastAPI(title="Audiofy – Musikwünsche", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
app = FastAPI(title="Playwish – Musikwünsche", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
@app.middleware("http")
+2 -2
View File
@@ -1,4 +1,4 @@
"""Audiofy-Web – Player-API (/api/player/…), geschützt per Bearer-Token (PLAYER_API_TOKEN).
"""Playwish-Web – Player-API (/api/player/…), geschützt per Bearer-Token (PLAYER_API_TOKEN).
Der Player baut nur ausgehende Verbindungen auf und fragt hier ca. alle 3 s nach:
GET /api/player/config Einstellungen + offene Befehle (Ziel: Reaktion < 3 s)
@@ -53,7 +53,7 @@ class JetztLaeuft(BaseModel):
source: Literal["playlist", "wish", "owner", "fremd"] | None = None
wish_id: int | None = None
next: Titel | None = None # bereits eingereihter nächster Titel
device_ok: bool = True # Gerät "Audiofy" erreichbar
device_ok: bool = True # Gerät "Playwish" erreichbar
speaker_ok: bool = True # Bluetooth-Lautsprecher verbunden
stopped: bool = False # Controller im Not-Aus
volume_percent: int | None = Field(default=None, ge=0, le=100)
+2 -2
View File
@@ -1,4 +1,4 @@
"""Audiofy-Web – Spotify-Zugriff für Suche und Titelinfos.
"""Playwish-Web – Spotify-Zugriff für Suche und Titelinfos.
Eigener Refresh-Token des Servers (auth.py --profil server, keine Scopes nötig).
Im Development Mode liefert die Suche höchstens 10 Treffer.
@@ -14,7 +14,7 @@ import httpx
from .config import Konfig, env_wert_setzen, konfig
log = logging.getLogger("audiofy.spotify")
log = logging.getLogger("playwish.spotify")
API = "https://api.spotify.com/v1"
TOKEN_URL = "https://accounts.spotify.com/api/token"
+1 -1
View File
@@ -1,4 +1,4 @@
/* Audiofy – Admin-Bereich (ergänzt app.css) */
/* Playwish – Admin-Bereich (ergänzt app.css) */
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 4px 0 12px; }
.kopf h1 { font-size: 1.4rem; }
+2 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#121212">
<meta name="robots" content="noindex">
<title>Audiofy – Admin</title>
<title>Playwish – Admin</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/admin.css">
<script src="/static/admin.js" defer></script>
@@ -13,7 +13,7 @@
<body>
<main>
<div class="kopf">
<h1>Audiofy Admin</h1>
<h1>Playwish Admin</h1>
<button id="abmelden" class="klein" type="button">Abmelden</button>
</div>
<div class="pillen" id="pillen"></div>
+1 -1
View File
@@ -1,4 +1,4 @@
// Audiofy – Admin-Bereich. Daten nur per textContent einsetzen (kein innerHTML mit fremden Inhalten).
// Playwish – Admin-Bereich. Daten nur per textContent einsetzen (kein innerHTML mit fremden Inhalten).
"use strict";
const ABFRAGE_MS = 3000;
+1 -1
View File
@@ -1,4 +1,4 @@
/* Audiofy – Gastseite (mobil zuerst) */
/* Playwish – Gastseite (mobil zuerst) */
:root {
--bg: #121212;
--karte: #1e1e1e;
+4 -4
View File
@@ -1,4 +1,4 @@
// Audiofy – Gastseite. Daten nur per textContent einsetzen (kein innerHTML mit fremden Inhalten).
// Playwish – Gastseite. Daten nur per textContent einsetzen (kein innerHTML mit fremden Inhalten).
"use strict";
const STATUS_ALLE_MS = 5000;
@@ -193,7 +193,7 @@ async function suchen(q) {
async function wuenschen(titel, knopf) {
knopf.disabled = true;
const name = $("name").value.trim();
try { localStorage.setItem("audiofy-name", name); } catch (_) { /* privat */ }
try { localStorage.setItem("playwish-name", name); } catch (_) { /* privat */ }
try {
if (!schutz.bereit()) knopf.textContent = "Prüfung …";
const turnstile = await schutz.token();
@@ -214,7 +214,7 @@ async function wuenschen(titel, knopf) {
}
function start() {
try { $("name").value = localStorage.getItem("audiofy-name") || ""; } catch (_) { /* privat */ }
try { $("name").value = localStorage.getItem("playwish-name") || ""; } catch (_) { /* privat */ }
$("suchfeld").addEventListener("input", (ereignis) => {
clearTimeout(sucheTimer);
@@ -226,7 +226,7 @@ function start() {
if (ereignis.key === "Enter") { ereignis.target.blur(); } // Tastatur auf dem Handy schließen
});
window.AudiofyTurnstile.einrichten($("turnstile"), "wish").then((s) => { schutz = s; });
window.PlaywishTurnstile.einrichten($("turnstile"), "wish").then((s) => { schutz = s; });
ladeStatus();
setInterval(() => { if (!document.hidden) ladeStatus(); }, STATUS_ALLE_MS);
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#121212">
<title>Audiofy – Musikwünsche</title>
<title>Playwish – Musikwünsche</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="preconnect" href="https://challenges.cloudflare.com">
<script src="/static/turnstile.js" defer></script>
@@ -13,7 +13,7 @@
<body>
<main>
<header>
<h1>Audiofy</h1>
<h1>Playwish</h1>
<p class="untertitel">Musikwünsche</p>
</header>
+2 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#121212">
<meta name="robots" content="noindex">
<title>Audiofy – Admin</title>
<title>Playwish – Admin</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="stylesheet" href="/static/admin.css">
<link rel="preconnect" href="https://challenges.cloudflare.com">
@@ -15,7 +15,7 @@
<body>
<main>
<header>
<h1>Audiofy</h1>
<h1>Playwish</h1>
<p class="untertitel">Admin-Bereich</p>
</header>
<form id="login" class="karte">
+2 -2
View File
@@ -1,8 +1,8 @@
// Audiofy – Admin-Login
// Playwish – Admin-Login
"use strict";
let schutz = { token: async () => null, reset() {}, bereit: () => true }; // Turnstile (falls aktiv)
window.AudiofyTurnstile.einrichten(document.getElementById("turnstile"), "login").then((s) => { schutz = s; });
window.PlaywishTurnstile.einrichten(document.getElementById("turnstile"), "login").then((s) => { schutz = s; });
document.getElementById("login").addEventListener("submit", async (ereignis) => {
ereignis.preventDefault();
+2 -2
View File
@@ -1,7 +1,7 @@
// Audiofy – Cloudflare Turnstile (Bot-Schutz). Lädt das Widget nur, wenn der Server einen Site-Key liefert.
// Playwish – Cloudflare Turnstile (Bot-Schutz). Lädt das Widget nur, wenn der Server einen Site-Key liefert.
"use strict";
window.AudiofyTurnstile = (() => {
window.PlaywishTurnstile = (() => {
let ladeVersprechen = null;
function skriptLaden() {
+2 -2
View File
@@ -5,13 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#121212">
<meta name="robots" content="noindex">
<title>Audiofy – Musikwünsche</title>
<title>Playwish – Musikwünsche</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<main>
<header>
<h1>Audiofy</h1>
<h1>Playwish</h1>
<p class="untertitel">Musikwünsche</p>
</header>
<section class="karte">
+2 -2
View File
@@ -1,4 +1,4 @@
"""Audiofy-Web – Cloudflare Turnstile: Bot-Schutz für Admin-Login und Wünsche.
"""Playwish-Web – Cloudflare Turnstile: Bot-Schutz für Admin-Login und Wünsche.
Kein Domain-Umzug nötig: Der Browser holt sich bei Cloudflare ein Token, der
Server prüft es per siteverify. Ohne TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEY in
@@ -16,7 +16,7 @@ from fastapi import HTTPException, Request
from .config import konfig
log = logging.getLogger("audiofy.turnstile")
log = logging.getLogger("playwish.turnstile")
VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
+2 -2
View File
@@ -14,9 +14,9 @@ PASSWORT = "richtig-langes-passwort"
@pytest.fixture
def client(tmp_path, monkeypatch):
env = tmp_path / ".env"
env.write_text(f"DB_PATH={tmp_path / 'audiofy.db'}\nSESSION_SECRET=geheim\nCOOKIE_SECURE=0\n"
env.write_text(f"DB_PATH={tmp_path / 'playwish.db'}\nSESSION_SECRET=geheim\nCOOKIE_SECURE=0\n"
f"PUBLIC_URL=https://musik.example.de\nADMIN_PASSWORD_HASH={PasswordHasher().hash(PASSWORT)}\n")
monkeypatch.setenv("AUDIOFY_ENV", str(env))
monkeypatch.setenv("PLAYWISH_ENV", str(env))
config.konfig.cache_clear()
for name in ("suche_drossel", "wunsch_drossel", "einladung_drossel"):
monkeypatch.setattr(gaeste, name, gaeste.Drossel(1000, 60))
+2 -2
View File
@@ -35,8 +35,8 @@ class FakeSpotify:
@pytest.fixture
def client(tmp_path, monkeypatch):
env = tmp_path / ".env"
env.write_text(f"DB_PATH={tmp_path / 'audiofy.db'}\nSESSION_SECRET=geheim\nCOOKIE_SECURE=0\n")
monkeypatch.setenv("AUDIOFY_ENV", str(env))
env.write_text(f"DB_PATH={tmp_path / 'playwish.db'}\nSESSION_SECRET=geheim\nCOOKIE_SECURE=0\n")
monkeypatch.setenv("PLAYWISH_ENV", str(env))
config.konfig.cache_clear()
for name in ("suche_drossel", "wunsch_drossel", "einladung_drossel"):
monkeypatch.setattr(gaeste, name, gaeste.Drossel(1000, 60))
+2 -2
View File
@@ -15,8 +15,8 @@ URI_C = "spotify:track:" + "C" * 22
@pytest.fixture
def client(tmp_path, monkeypatch):
env = tmp_path / ".env"
env.write_text(f"PLAYER_API_TOKEN={TOKEN}\nDB_PATH={tmp_path / 'audiofy.db'}\nSPOTIFY_PLAYLIST_ID=startliste\n")
monkeypatch.setenv("AUDIOFY_ENV", str(env))
env.write_text(f"PLAYER_API_TOKEN={TOKEN}\nDB_PATH={tmp_path / 'playwish.db'}\nSPOTIFY_PLAYLIST_ID=startliste\n")
monkeypatch.setenv("PLAYWISH_ENV", str(env))
config.konfig.cache_clear()
from app.main import app
with TestClient(app) as c:
+2 -2
View File
@@ -30,10 +30,10 @@ def antworten(monkeypatch):
@pytest.fixture
def client(tmp_path, monkeypatch, antworten):
env = tmp_path / ".env"
env.write_text(f"DB_PATH={tmp_path / 'audiofy.db'}\nSESSION_SECRET=geheim\nCOOKIE_SECURE=0\n"
env.write_text(f"DB_PATH={tmp_path / 'playwish.db'}\nSESSION_SECRET=geheim\nCOOKIE_SECURE=0\n"
f"PUBLIC_URL=https://musik.example.de\nADMIN_PASSWORD_HASH={PasswordHasher().hash(PASSWORT)}\n"
"TURNSTILE_SITE_KEY=site-oeffentlich\nTURNSTILE_SECRET_KEY=secret-geheim\n")
monkeypatch.setenv("AUDIOFY_ENV", str(env))
monkeypatch.setenv("PLAYWISH_ENV", str(env))
config.konfig.cache_clear()
for name in ("suche_drossel", "wunsch_drossel", "einladung_drossel"):
monkeypatch.setattr(gaeste, name, gaeste.Drossel(1000, 60))
+6 -6
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Audiofy-Web – Verwaltung auf der Kommandozeile.
"""Playwish-Web – Verwaltung auf der Kommandozeile.
geheimnisse SESSION_SECRET und PLAYER_API_TOKEN erzeugen (nur falls noch nicht vorhanden)
passwort Admin-Passwort setzen (verdeckte Eingabe, gespeichert wird nur der argon2-Hash)
turnstile Cloudflare-Turnstile-Schlüssel setzen (Secret-Key verdeckt)
Auf dem Server als Dienst-Benutzer ausführen, damit die .env ihm gehört:
sudo -u audiofy /opt/audiofy/.venv/bin/python /opt/audiofy/app/verwaltung.py passwort
sudo -u playwish /opt/playwish/.venv/bin/python /opt/playwish/app/verwaltung.py passwort
"""
import argparse
@@ -46,7 +46,7 @@ def passwort(env: Path) -> None:
if getpass.getpass("Wiederholen: ") != erstes:
sys.exit("Die Eingaben stimmen nicht überein.")
env_wert_setzen(env, "ADMIN_PASSWORD_HASH", PasswordHasher().hash(erstes))
print(f"Passwort-Hash in {env} gespeichert. Bitte den Dienst neu starten (audiofy-web.service).")
print(f"Passwort-Hash in {env} gespeichert. Bitte den Dienst neu starten (playwish-web.service).")
def turnstile(env: Path) -> None:
@@ -56,13 +56,13 @@ def turnstile(env: Path) -> None:
sys.exit("Beide Schlüssel sind nötig.")
env_wert_setzen(env, "TURNSTILE_SITE_KEY", site_key)
env_wert_setzen(env, "TURNSTILE_SECRET_KEY", secret)
print(f"Turnstile-Schlüssel in {env} gespeichert. Bitte den Dienst neu starten (audiofy-web.service).")
print(f"Turnstile-Schlüssel in {env} gespeichert. Bitte den Dienst neu starten (playwish-web.service).")
def main() -> None:
parser = argparse.ArgumentParser(description="Audiofy-Web – Verwaltung")
parser = argparse.ArgumentParser(description="Playwish-Web – Verwaltung")
parser.add_argument("aktion", choices=("geheimnisse", "passwort", "turnstile"))
parser.add_argument("--env", type=Path, default=Path(os.environ.get("AUDIOFY_ENV", "/opt/audiofy/.env")))
parser.add_argument("--env", type=Path, default=Path(os.environ.get("PLAYWISH_ENV", "/opt/playwish/.env")))
args = parser.parse_args()
{"geheimnisse": geheimnisse, "passwort": passwort, "turnstile": turnstile}[args.aktion](args.env)