diff --git a/assets/css/main.css b/assets/css/main.css index 7da0f81..1dd203a 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -777,14 +777,22 @@ footer.site-footer { width: 10px; height: 10px; border-radius: 50%; - background: #6b7280; /* grau = aktuell offline */ + background: #e05260; /* rot = offline (Standard) */ + box-shadow: 0 0 8px rgba(224, 82, 96, .55); margin-right: .45rem; vertical-align: middle; + transition: background .3s ease, box-shadow .3s ease; } .live-dot.is-live { background: #22c55e; /* grün = live */ animation: live-pulse 1.6s infinite; } +/* Solange der Status noch geladen wird: neutral grau, damit nie kurz + fälschlich "offline" aufblitzt. */ +.live-dot.is-unknown { + background: #6b7280; + box-shadow: none; +} @keyframes live-pulse { 0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, .55); } 70% { box-shadow: 0 0 0 10px rgba(34, 197, 94, 0); } diff --git a/assets/js/postfach.js b/assets/js/postfach.js index a406236..46b7c53 100644 --- a/assets/js/postfach.js +++ b/assets/js/postfach.js @@ -162,17 +162,56 @@ async function loadNotes(applicationId) { .join("") || `

Noch keine Notizen.

`; } -/* ---------------- Live-Status ---------------- */ -function renderLiveStatus(live) { - $("admin-live-dot").classList.toggle("is-live", !!live); - $("admin-live-text").textContent = live ? "Live jetzt!" : "Aktuell offline"; +/* ---------------- Live-Status ---------------- + Der Status kommt automatisch von TikTok (Cron im Worker, jede Minute). + Die Handschaltung ist nur ein zeitlich begrenzter Vorrang. + ------------------------------------------------------------------- */ +function formatSince(iso) { + if (!iso) return null; + const diffMin = Math.floor((Date.now() - new Date(iso).getTime()) / 60000); + if (diffMin < 1) return "gerade eben"; + if (diffMin === 1) return "vor 1 Minute"; + if (diffMin < 60) return `vor ${diffMin} Minuten`; + const h = Math.floor(diffMin / 60); + return h === 1 ? "vor 1 Stunde" : `vor ${h} Stunden`; +} + +function renderLiveStatus(data) { + const live = !!(data && data.live); + const dot = $("admin-live-dot"); + dot.classList.remove("is-unknown"); + dot.classList.toggle("is-live", live); + $("admin-live-text").textContent = live ? "Jetzt live" : "Gerade offline"; + + const manual = data && data.source === "manuell"; + $("go-auto-btn").style.display = manual ? "inline-flex" : "none"; + + const lines = []; + if (manual) { + lines.push("⚠️ Von Hand gesetzt — die Automatik ist gerade übersteuert."); + if (data.overrideUntil) { + const until = new Date(data.overrideUntil); + lines.push(`Läuft aus um ${until.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr.`); + } + if (data.autoLive !== null && data.autoLive !== undefined) { + lines.push(`TikTok meldet gerade: ${data.autoLive ? "live" : "offline"}.`); + } + } else { + if (data && data.title && live) lines.push(`Titel: ${data.title}`); + const since = formatSince(data && data.autoCheckedAt); + if (since) lines.push(`Automatisch geprüft ${since}.`); + if (data && data.autoHealthy === false) { + lines.push("⚠️ Der letzte Abruf bei TikTok hat nicht geklappt — angezeigt wird der letzte bekannte Stand."); + } + } + $("admin-live-detail").innerHTML = lines.join("
"); } async function loadLiveStatus() { try { - const res = await fetch(`${API_BASE_URL}/live-status`); + const res = await fetch(`${API_BASE_URL}/live-status`, { cache: "no-store" }); const data = await res.json(); - if (res.ok && data.ok) renderLiveStatus(data.live); + if (res.ok && data.ok) renderLiveStatus(data); } catch { /* keine Blockade */ } @@ -180,7 +219,12 @@ async function loadLiveStatus() { async function setLiveStatus(live) { const res = await apiCall(API_BASE_URL, "/live-status/set", { live }); - if (res.ok) renderLiveStatus(res.live); + if (res.ok) renderLiveStatus(res); +} + +async function backToAutoLive() { + const res = await apiCall(API_BASE_URL, "/live-status/set", { auto: true }); + if (res.ok) renderLiveStatus(res); } /* ---------------- Login / Logout ---------------- */ @@ -213,4 +257,10 @@ document.addEventListener("DOMContentLoaded", () => { $("show-archived").addEventListener("change", loadInbox); $("go-live-btn").addEventListener("click", () => setLiveStatus(true)); $("go-offline-btn").addEventListener("click", () => setLiveStatus(false)); + $("go-auto-btn").addEventListener("click", backToAutoLive); + + // Anzeige alle 30 Sekunden auffrischen, damit man die Automatik arbeiten sieht. + setInterval(() => { + if (!document.hidden && getSession()) loadLiveStatus(); + }, 30000); }); diff --git a/cloudflare-worker/src/lib/tiktok-live.js b/cloudflare-worker/src/lib/tiktok-live.js new file mode 100644 index 0000000..d427d82 --- /dev/null +++ b/cloudflare-worker/src/lib/tiktok-live.js @@ -0,0 +1,93 @@ +/* ===================================================================== + lib/tiktok-live.js — Automatische Live-Erkennung für TikTok + + TikTok bietet KEINE offizielle API, die einem sagt "ist Nutzer X gerade + live?". Was aber öffentlich funktioniert: die Seite + https://www.tiktok.com/@/live liefert im HTML ein JSON-Objekt + (", open); + if (end === -1) return null; + try { + return JSON.parse(html.slice(open + 1, end)); + } catch { + return null; + } +} + +/** + * Fragt bei TikTok nach, ob der Kanal gerade live ist. + * @returns {Promise<{ok:boolean, live?:boolean, title?:string, roomId?:string, + * followers?:number, rawStatus?:number, reason?:string}>} + */ +export async function checkTikTokLive(username) { + const handle = String(username || "").replace(/^@/, "").trim(); + if (!handle) return { ok: false, reason: "kein-benutzername" }; + + let res; + try { + res = await fetch(`https://www.tiktok.com/@${encodeURIComponent(handle)}/live`, { + headers: BROWSER_HEADERS, + redirect: "follow", + cf: { cacheTtl: 0, cacheEverything: false }, + }); + } catch (err) { + return { ok: false, reason: `netzwerkfehler: ${String(err).slice(0, 120)}` }; + } + + if (!res.ok) return { ok: false, reason: `http-${res.status}` }; + + const html = await res.text(); + if (html.length < 5000) return { ok: false, reason: "seite-zu-klein-vermutlich-blockiert" }; + + // Bevorzugt SIGI_STATE, ersatzweise das neuere Rehydration-Objekt. + const sigi = extractScriptJson(html, "SIGI_STATE"); + let info = sigi?.LiveRoom?.liveRoomUserInfo; + + if (!info) { + const universal = extractScriptJson(html, "__UNIVERSAL_DATA_FOR_REHYDRATION__"); + info = universal?.__DEFAULT_SCOPE__?.["webapp.live-detail"]?.liveRoomUserInfo; + } + if (!info) return { ok: false, reason: "struktur-nicht-gefunden" }; + + const status = info.liveRoom?.status; + if (typeof status !== "number") return { ok: false, reason: "kein-status-feld" }; + + return { + ok: true, + live: status === LIVE_STATUS_RUNNING, + rawStatus: status, + title: info.liveRoom?.title || null, + roomId: info.user?.roomId ? String(info.user.roomId) : null, + followers: info.stats?.followerCount ?? null, + }; +} diff --git a/cloudflare-worker/src/worker.js b/cloudflare-worker/src/worker.js index 9df6efd..ff57281 100644 --- a/cloudflare-worker/src/worker.js +++ b/cloudflare-worker/src/worker.js @@ -39,21 +39,149 @@ import { listNotes, addNote, deleteNote, listDrafts, saveDraft, } from "./routes/applications.js"; import { listAuditLog } from "./routes/audit.js"; +import { checkTikTokLive } from "./lib/tiktok-live.js"; +import { logAction } from "./lib/audit.js"; -const LIVE_STATUS_KEY = "meta:live"; +/* ===================================================================== + LIVE-STATUS -async function handleLiveStatusGet(env) { - const raw = await env.BEWERBUNGEN.get(LIVE_STATUS_KEY); - const data = raw ? JSON.parse(raw) : { live: false, updatedAt: null }; - return json({ ok: true, ...data }); + Der grüne Punkt auf der Startseite läuft automatisch: ein Cron-Auftrag + fragt jede Minute bei TikTok nach, ob @dogfather0804 gerade sendet, und + schreibt das Ergebnis in die Tabelle app_settings. + + Zwei Schlüssel: + live_auto – letzter ERFOLGREICH ermittelter Stand (wird bei einem + fehlgeschlagenen Abruf bewusst NICHT überschrieben, + damit ein TikTok-Aussetzer den Punkt nicht fälschlich + auf offline zieht) + live_override – manueller Vorrang aus dem Postfach, mit Ablaufzeit. + Solange er gilt, schlägt er die Automatik. + ===================================================================== */ + +const DEFAULT_OVERRIDE_MINUTES = 180; + +async function readSetting(env, key) { + const row = await env.DB.prepare(`SELECT value FROM app_settings WHERE key = ?`) + .bind(key) + .first() + .catch(() => null); + if (!row || !row.value) return null; + try { + return JSON.parse(row.value); + } catch { + return null; + } } +async function writeSetting(env, key, value) { + await env.DB.prepare( + `INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at` + ) + .bind(key, JSON.stringify(value), new Date().toISOString()) + .run(); +} + +/** Ermittelt den Stand, der nach außen gilt (Vorrang: manuell vor Automatik). */ +async function resolveLiveStatus(env) { + const auto = (await readSetting(env, "live_auto")) || {}; + const override = await readSetting(env, "live_override"); + + const overrideActive = + override && override.until && new Date(override.until).getTime() > Date.now(); + + if (overrideActive) { + return { + live: !!override.live, + source: "manuell", + updatedAt: override.setAt || null, + overrideUntil: override.until, + autoLive: auto.live ?? null, + autoCheckedAt: auto.checkedAt || null, + autoHealthy: auto.ok !== false, + }; + } + + return { + live: !!auto.live, + source: "automatisch", + updatedAt: auto.changedAt || auto.checkedAt || null, + title: auto.title || null, + autoLive: auto.live ?? null, + autoCheckedAt: auto.checkedAt || null, + autoHealthy: auto.ok !== false, + }; +} + +async function handleLiveStatusGet(env) { + const state = await resolveLiveStatus(env); + return json({ ok: true, ...state }); +} + +/** + * Manueller Eingriff aus dem Postfach. + * { live: true|false, minutes?: number } → Vorrang setzen + * { auto: true } → Vorrang aufheben, Automatik übernimmt + */ async function handleLiveStatusSet(request, env, session) { if (!session?.isOwner) return json({ ok: false, error: "Nur Dogi darf den Live-Status ändern." }, 403); const body = await request.json().catch(() => ({})); - const record = { live: !!body.live, updatedAt: new Date().toISOString() }; - await env.BEWERBUNGEN.put(LIVE_STATUS_KEY, JSON.stringify(record)); - return json({ ok: true, ...record }); + + if (body.auto === true) { + await env.DB.prepare(`DELETE FROM app_settings WHERE key = 'live_override'`).run(); + await logAction(env, "owner", "live.override_cleared", null, null); + return json({ ok: true, ...(await resolveLiveStatus(env)) }); + } + + const minutes = Number.isFinite(body.minutes) ? Math.min(Math.max(body.minutes, 5), 1440) : DEFAULT_OVERRIDE_MINUTES; + const now = new Date(); + await writeSetting(env, "live_override", { + live: !!body.live, + setAt: now.toISOString(), + until: new Date(now.getTime() + minutes * 60_000).toISOString(), + }); + await logAction(env, "owner", "live.override_set", null, { live: !!body.live, minutes }); + return json({ ok: true, ...(await resolveLiveStatus(env)) }); +} + +/** Wird vom Cron-Auftrag aufgerufen — fragt TikTok ab und schreibt das Ergebnis. */ +async function refreshLiveFromTikTok(env) { + const username = env.TIKTOK_USERNAME || "dogfather0804"; + const result = await checkTikTokLive(username); + const now = new Date().toISOString(); + + if (!result.ok) { + // Fehlgeschlagen: letzten guten Stand behalten, nur den Fehler vermerken. + const previous = (await readSetting(env, "live_auto")) || {}; + await writeSetting(env, "live_auto", { + ...previous, + ok: false, + lastError: result.reason, + checkedAt: now, + }); + return { ok: false, reason: result.reason }; + } + + const previous = (await readSetting(env, "live_auto")) || {}; + const changed = previous.live !== result.live; + + await writeSetting(env, "live_auto", { + ok: true, + live: result.live, + title: result.title, + roomId: result.roomId, + rawStatus: result.rawStatus, + checkedAt: now, + changedAt: changed ? now : previous.changedAt || now, + lastError: null, + }); + + if (changed) { + await logAction(env, "system", result.live ? "live.started" : "live.ended", null, { + title: result.title, + }); + } + return { ok: true, live: result.live, changed }; } // Routen, die eine gültige Session brauchen. `session` wird injiziert. @@ -130,4 +258,9 @@ export default { return json({ ok: false, error: "Not found." }, 404); }, + + /* Cron-Auftrag: läuft jede Minute und hält den Live-Punkt aktuell. */ + async scheduled(event, env, ctx) { + ctx.waitUntil(refreshLiveFromTikTok(env)); + }, }; diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml index 2bb353e..9465e74 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -7,6 +7,14 @@ name = "dogfather-universe-postfach" main = "src/worker.js" compatibility_date = "2024-01-01" +# TikTok-Kanal, dessen Live-Status automatisch geprüft wird. +[vars] +TIKTOK_USERNAME = "dogfather0804" + +# Cron: jede Minute bei TikTok nachsehen, ob gerade gesendet wird. +[triggers] +crons = ["* * * * *"] + [[kv_namespaces]] binding = "BEWERBUNGEN" id = "f8f8a692150f452cab5e334743194fe2" diff --git a/index.html b/index.html index 9552d87..feed4ee 100644 --- a/index.html +++ b/index.html @@ -53,7 +53,7 @@

Drei Welten, eine Familie: Team Dogi.

- Aktuell offline + Status wird geladen …

Dogfather geht jeden Tag ab 20 Uhr live — oft schon tagsüber, zu wechselnden Zeiten. Streamplan ansehen →

@@ -268,23 +268,44 @@ document.addEventListener("DOMContentLoaded", () => { ); }); - // Live-Status: grüner Punkt, wenn Dogfather gerade live ist + // Live-Status: grün = läuft gerade, rot = offline. + // Der Wert kommt automatisch von TikTok (Cron im Worker prüft jede Minute). + // Die Seite fragt alle 30 Sekunden nach, damit der Punkt ohne Neuladen umspringt. const liveDot = document.getElementById("live-dot"); const liveText = document.getElementById("live-text"); + if (API_BASE_URL && liveDot && liveText) { - fetch(`${API_BASE_URL}/live-status`) - .then((res) => res.json()) - .then((data) => { - if (data && data.live) { - liveDot.classList.add("is-live"); - liveText.textContent = "Live jetzt!"; - } else { - liveText.textContent = "Aktuell offline"; - } - }) - .catch(() => { - liveText.textContent = "Aktuell offline"; - }); + liveDot.classList.add("is-unknown"); + liveText.textContent = "Status wird geladen …"; + + const applyLiveStatus = (data) => { + const isLive = !!(data && data.live); + liveDot.classList.remove("is-unknown"); + liveDot.classList.toggle("is-live", isLive); + + if (isLive) { + liveText.textContent = data.title ? `Jetzt live: ${data.title}` : "Jetzt live!"; + } else { + liveText.textContent = "Gerade offline"; + } + }; + + const refreshLiveStatus = () => + fetch(`${API_BASE_URL}/live-status`, { cache: "no-store" }) + .then((res) => res.json()) + .then(applyLiveStatus) + .catch(() => { + liveDot.classList.remove("is-live"); + liveDot.classList.add("is-unknown"); + liveText.textContent = "Status gerade nicht abrufbar"; + }); + + refreshLiveStatus(); + setInterval(refreshLiveStatus, 30000); + // Beim Zurückwechseln zum Tab sofort aktualisieren statt bis zu 30 s warten. + document.addEventListener("visibilitychange", () => { + if (!document.hidden) refreshLiveStatus(); + }); } // Stats: Team-Größe aus den Datenobjekten berechnen diff --git a/postfach.html b/postfach.html index 5ca1f6f..3543bea 100644 --- a/postfach.html +++ b/postfach.html @@ -67,13 +67,25 @@