This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -10,29 +10,29 @@ export const dynamic = "force-dynamic";
|
||||
const AUTOPLAY_KEYS = ["radio_auto_play", "radio_autoplay"];
|
||||
|
||||
function truthy(value: string | undefined): boolean {
|
||||
if (value === undefined) return false;
|
||||
const s = value.trim().toLowerCase();
|
||||
return s === "1" || s === "true";
|
||||
if (value === undefined) return false;
|
||||
const s = value.trim().toLowerCase();
|
||||
return s === "1" || s === "true";
|
||||
}
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: AUTOPLAY_KEYS } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: AUTOPLAY_KEYS } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
map[row.key] = row.value;
|
||||
}
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
map[row.key] = row.value;
|
||||
}
|
||||
|
||||
// Prefer the canonical key the settings page uses; fall back to the alias.
|
||||
const autoplay = truthy(map.radio_auto_play ?? map.radio_autoplay);
|
||||
// Prefer the canonical key the settings page uses; fall back to the alias.
|
||||
const autoplay = truthy(map.radio_auto_play ?? map.radio_autoplay);
|
||||
|
||||
return apiJson({ autoplay });
|
||||
} catch {
|
||||
// DB unavailable — default to no autoplay rather than a 500.
|
||||
return apiJson({ autoplay: false }, { status: 200 });
|
||||
}
|
||||
return apiJson({ autoplay });
|
||||
} catch {
|
||||
// DB unavailable — default to no autoplay rather than a 500.
|
||||
return apiJson({ autoplay: false }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -9,37 +9,37 @@ export const dynamic = "force-dynamic";
|
||||
// radio_* keys relevant to the public player widget. Mirrors what the AtomCMS
|
||||
// radio-player blade requests from /api/radio/config.
|
||||
const CONFIG_KEYS = new Set([
|
||||
"radio_enabled",
|
||||
"radio_name",
|
||||
"radio_stream_url",
|
||||
"radio_stream_backup_url",
|
||||
"radio_auto_play",
|
||||
"radio_auto_play_delay",
|
||||
"radio_mute_on_start",
|
||||
"radio_volume",
|
||||
"radio_style",
|
||||
"radio_player_type",
|
||||
"radio_logo_url",
|
||||
"radio_widget_enabled",
|
||||
"radio_widget_show_globally",
|
||||
"radio_widget_position",
|
||||
"radio_enabled",
|
||||
"radio_name",
|
||||
"radio_stream_url",
|
||||
"radio_stream_backup_url",
|
||||
"radio_auto_play",
|
||||
"radio_auto_play_delay",
|
||||
"radio_mute_on_start",
|
||||
"radio_volume",
|
||||
"radio_style",
|
||||
"radio_player_type",
|
||||
"radio_logo_url",
|
||||
"radio_widget_enabled",
|
||||
"radio_widget_show_globally",
|
||||
"radio_widget_position",
|
||||
]);
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: Array.from(CONFIG_KEYS) } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: Array.from(CONFIG_KEYS) } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
const config: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
config[row.key] = row.value;
|
||||
}
|
||||
const config: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
config[row.key] = row.value;
|
||||
}
|
||||
|
||||
return apiJson(config);
|
||||
} catch {
|
||||
// DB unavailable — serve an empty config rather than a 500.
|
||||
return apiJson({}, { status: 200 });
|
||||
}
|
||||
return apiJson(config);
|
||||
} catch {
|
||||
// DB unavailable — serve an empty config rather than a 500.
|
||||
return apiJson({}, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -8,27 +8,27 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const raw = await siteSettings.get("radio_current_dj_id", "");
|
||||
const id = Number(raw);
|
||||
try {
|
||||
const raw = await siteSettings.get("radio_current_dj_id", "");
|
||||
const id = Number(raw);
|
||||
|
||||
// No DJ configured (empty / non-numeric / zero).
|
||||
if (!raw || !Number.isFinite(id) || id <= 0) {
|
||||
return apiJson({ dj: null });
|
||||
}
|
||||
// No DJ configured (empty / non-numeric / zero).
|
||||
if (!raw || !Number.isFinite(id) || id <= 0) {
|
||||
return apiJson({ dj: null });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { username: true, look: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return apiJson({ dj: null });
|
||||
}
|
||||
if (!user) {
|
||||
return apiJson({ dj: null });
|
||||
}
|
||||
|
||||
return apiJson({ dj: { username: user.username, look: user.look } });
|
||||
} catch {
|
||||
// DB / settings unavailable — no DJ rather than a 500.
|
||||
return apiJson({ dj: null }, { status: 200 });
|
||||
}
|
||||
return apiJson({ dj: { username: user.username, look: user.look } });
|
||||
} catch {
|
||||
// DB / settings unavailable — no DJ rather than a 500.
|
||||
return apiJson({ dj: null }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -6,23 +6,28 @@ import { prisma } from "@/lib/prisma";
|
||||
// flat { key: value } map. None of these keys are secrets.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const EMBED_KEYS = ["radio_enabled", "radio_name", "radio_stream_url", "radio_auto_play"];
|
||||
const EMBED_KEYS = [
|
||||
"radio_enabled",
|
||||
"radio_name",
|
||||
"radio_stream_url",
|
||||
"radio_auto_play",
|
||||
];
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: EMBED_KEYS } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
try {
|
||||
const rows = await prisma.websiteSetting.findMany({
|
||||
where: { key: { in: EMBED_KEYS } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
|
||||
const config: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
config[row.key] = row.value;
|
||||
}
|
||||
const config: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
config[row.key] = row.value;
|
||||
}
|
||||
|
||||
return apiJson(config);
|
||||
} catch {
|
||||
// DB unavailable — serve an empty config rather than a 500.
|
||||
return apiJson({}, { status: 200 });
|
||||
}
|
||||
return apiJson(config);
|
||||
} catch {
|
||||
// DB unavailable — serve an empty config rather than a 500.
|
||||
return apiJson({}, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -9,80 +9,97 @@ export const dynamic = "force-dynamic";
|
||||
const FETCH_TIMEOUT_MS = 4000;
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
// Best-effort listener-count extraction across the common provider shapes
|
||||
// (AzureCast nests under listeners.current/total; others expose num_listeners,
|
||||
// listeners, unique_listeners, count, or a bare number).
|
||||
function findCount(value: unknown, depth = 0): number | null {
|
||||
if (depth > 4) return null;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
|
||||
return Number(value);
|
||||
}
|
||||
if (!isRecord(value)) return null;
|
||||
if (depth > 4) return null;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
value.trim() !== "" &&
|
||||
Number.isFinite(Number(value))
|
||||
) {
|
||||
return Number(value);
|
||||
}
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const keys = ["current", "total", "num_listeners", "listeners", "unique_listeners", "count"];
|
||||
for (const key of keys) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
||||
const v = value[key];
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) {
|
||||
return Number(v);
|
||||
}
|
||||
}
|
||||
for (const v of Object.values(value)) {
|
||||
if (isRecord(v)) {
|
||||
const found = findCount(v, depth + 1);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
const keys = [
|
||||
"current",
|
||||
"total",
|
||||
"num_listeners",
|
||||
"listeners",
|
||||
"unique_listeners",
|
||||
"count",
|
||||
];
|
||||
for (const key of keys) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
||||
const v = value[key];
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (
|
||||
typeof v === "string" &&
|
||||
v.trim() !== "" &&
|
||||
Number.isFinite(Number(v))
|
||||
) {
|
||||
return Number(v);
|
||||
}
|
||||
}
|
||||
for (const v of Object.values(value)) {
|
||||
if (isRecord(v)) {
|
||||
const found = findCount(v, depth + 1);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
let url: string | null = null;
|
||||
try {
|
||||
url = (await siteSettings.get("radio_listeners_api_url", "")) || null;
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
let url: string | null = null;
|
||||
try {
|
||||
url = (await siteSettings.get("radio_listeners_api_url", "")) || null;
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
|
||||
// Not configured — no listener data available.
|
||||
if (!url) {
|
||||
return apiJson({ listeners: null });
|
||||
}
|
||||
// Not configured — no listener data available.
|
||||
if (!url) {
|
||||
return apiJson({ listeners: null });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
headers: { accept: "application/json, text/plain, */*" },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
headers: { accept: "application/json, text/plain, */*" },
|
||||
});
|
||||
|
||||
const raw = (await res.text()).trim();
|
||||
if (raw === "") {
|
||||
return apiJson({ listeners: null });
|
||||
}
|
||||
const raw = (await res.text()).trim();
|
||||
if (raw === "") {
|
||||
return apiJson({ listeners: null });
|
||||
}
|
||||
|
||||
let parsed: unknown = raw;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
// leave as raw string; findCount handles numeric strings.
|
||||
}
|
||||
let parsed: unknown = raw;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
// leave as raw string; findCount handles numeric strings.
|
||||
}
|
||||
|
||||
return apiJson({ listeners: findCount(parsed) });
|
||||
} catch (e) {
|
||||
const aborted = e instanceof Error && e.name === "AbortError";
|
||||
return apiJson({
|
||||
listeners: null,
|
||||
error: aborted ? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s` : "Fetch failed",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
return apiJson({ listeners: findCount(parsed) });
|
||||
} catch (e) {
|
||||
const aborted = e instanceof Error && e.name === "AbortError";
|
||||
return apiJson({
|
||||
listeners: null,
|
||||
error: aborted
|
||||
? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s`
|
||||
: "Fetch failed",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -9,44 +9,46 @@ export const dynamic = "force-dynamic";
|
||||
const FETCH_TIMEOUT_MS = 4000;
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
let url: string | null = null;
|
||||
try {
|
||||
url = (await siteSettings.get("radio_now_playing_api_url", "")) || null;
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
let url: string | null = null;
|
||||
try {
|
||||
url = (await siteSettings.get("radio_now_playing_api_url", "")) || null;
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
|
||||
// Not configured — there is nothing to play.
|
||||
if (!url) {
|
||||
return apiJson({ nowPlaying: null });
|
||||
}
|
||||
// Not configured — there is nothing to play.
|
||||
if (!url) {
|
||||
return apiJson({ nowPlaying: null });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
headers: { accept: "application/json, text/plain, */*" },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
headers: { accept: "application/json, text/plain, */*" },
|
||||
});
|
||||
|
||||
const raw = (await res.text()).trim();
|
||||
if (raw === "") {
|
||||
return apiJson({ nowPlaying: null });
|
||||
}
|
||||
const raw = (await res.text()).trim();
|
||||
if (raw === "") {
|
||||
return apiJson({ nowPlaying: null });
|
||||
}
|
||||
|
||||
// Return parsed JSON when the provider speaks JSON, else the raw text body.
|
||||
try {
|
||||
return apiJson(JSON.parse(raw));
|
||||
} catch {
|
||||
return apiJson({ nowPlaying: raw.slice(0, 2000) });
|
||||
}
|
||||
} catch (e) {
|
||||
const aborted = e instanceof Error && e.name === "AbortError";
|
||||
return apiJson({
|
||||
error: aborted ? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s` : "Fetch failed",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
// Return parsed JSON when the provider speaks JSON, else the raw text body.
|
||||
try {
|
||||
return apiJson(JSON.parse(raw));
|
||||
} catch {
|
||||
return apiJson({ nowPlaying: raw.slice(0, 2000) });
|
||||
}
|
||||
} catch (e) {
|
||||
const aborted = e instanceof Error && e.name === "AbortError";
|
||||
return apiJson({
|
||||
error: aborted
|
||||
? `Timed out after ${FETCH_TIMEOUT_MS / 1000}s`
|
||||
: "Fetch failed",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -7,42 +7,42 @@ import { prisma } from "@/lib/prisma";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
// Sum points per user. Sort/slice in JS so we stay adapter-agnostic about
|
||||
// aggregate ordering, then resolve the top 20 to usernames/looks.
|
||||
const grouped = await prisma.radioListenerPoints.groupBy({
|
||||
by: ["userId"],
|
||||
_sum: { points: true },
|
||||
});
|
||||
try {
|
||||
// Sum points per user. Sort/slice in JS so we stay adapter-agnostic about
|
||||
// aggregate ordering, then resolve the top 20 to usernames/looks.
|
||||
const grouped = await prisma.radioListenerPoints.groupBy({
|
||||
by: ["userId"],
|
||||
_sum: { points: true },
|
||||
});
|
||||
|
||||
const ranked = grouped
|
||||
.map((g) => ({ userId: g.userId, points: g._sum.points ?? 0 }))
|
||||
.sort((a, b) => b.points - a.points)
|
||||
.slice(0, 20);
|
||||
const ranked = grouped
|
||||
.map((g) => ({ userId: g.userId, points: g._sum.points ?? 0 }))
|
||||
.sort((a, b) => b.points - a.points)
|
||||
.slice(0, 20);
|
||||
|
||||
if (ranked.length === 0) {
|
||||
return apiJson({ data: [] });
|
||||
}
|
||||
if (ranked.length === 0) {
|
||||
return apiJson({ data: [] });
|
||||
}
|
||||
|
||||
const userIds = ranked.map((r) => r.userId);
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, username: true, look: true },
|
||||
});
|
||||
const userById = new Map(users.map((u) => [u.id, u]));
|
||||
const userIds = ranked.map((r) => r.userId);
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, username: true, look: true },
|
||||
});
|
||||
const userById = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const data = ranked.map((r) => {
|
||||
const u = userById.get(r.userId);
|
||||
return {
|
||||
username: u?.username ?? null,
|
||||
look: u?.look ?? null,
|
||||
points: r.points,
|
||||
};
|
||||
});
|
||||
const data = ranked.map((r) => {
|
||||
const u = userById.get(r.userId);
|
||||
return {
|
||||
username: u?.username ?? null,
|
||||
look: u?.look ?? null,
|
||||
points: r.points,
|
||||
};
|
||||
});
|
||||
|
||||
return apiJson({ data });
|
||||
} catch {
|
||||
// DB unavailable — serve an empty leaderboard rather than a 500.
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
return apiJson({ data });
|
||||
} catch {
|
||||
// DB unavailable — serve an empty leaderboard rather than a 500.
|
||||
return apiJson({ data: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
import { apiJson, apiError } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { apiError, apiJson } from "@/lib/api";
|
||||
import { bearerUserId } from "@/lib/api-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// The Bearer-authed user's total radio listener points: the sum of all
|
||||
// radio_listener_points.points rows for that user_id.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
try {
|
||||
const agg = await prisma.radioListenerPoints.aggregate({
|
||||
where: { userId: uid },
|
||||
_sum: { points: true },
|
||||
});
|
||||
try {
|
||||
const agg = await prisma.radioListenerPoints.aggregate({
|
||||
where: { userId: uid },
|
||||
_sum: { points: true },
|
||||
});
|
||||
|
||||
return apiJson({ points: agg._sum.points ?? 0 });
|
||||
} catch {
|
||||
// DB unavailable — report zero rather than a 500.
|
||||
return apiJson({ points: 0 }, { status: 200 });
|
||||
}
|
||||
return apiJson({ points: agg._sum.points ?? 0 });
|
||||
} catch {
|
||||
// DB unavailable — report zero rather than a 500.
|
||||
return apiJson({ points: 0 }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiJson, apiError } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { apiError, apiJson } from "@/lib/api";
|
||||
import { bearerUserId } from "@/lib/api-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Latest 50 radio shouts with their author's username/look resolved. Mirrors the
|
||||
// query behind the public /radio/shouts page (radio_shouts ordered by created_at
|
||||
@@ -11,72 +11,75 @@ export const dynamic = "force-dynamic";
|
||||
const MAX_MESSAGE_LENGTH = 255;
|
||||
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const shouts = await prisma.radioShouts.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
try {
|
||||
const shouts = await prisma.radioShouts.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
// Resolve author usernames/looks. radio_shouts.user_id is an UnsignedBigInt
|
||||
// while users.id is an Int, so narrow to Number for the lookup.
|
||||
const authorIds = Array.from(new Set(shouts.map((s) => Number(s.userId))));
|
||||
const authors = authorIds.length
|
||||
? await prisma.user.findMany({
|
||||
where: { id: { in: authorIds } },
|
||||
select: { id: true, username: true, look: true },
|
||||
})
|
||||
: [];
|
||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||
// Resolve author usernames/looks. radio_shouts.user_id is an UnsignedBigInt
|
||||
// while users.id is an Int, so narrow to Number for the lookup.
|
||||
const authorIds = Array.from(new Set(shouts.map((s) => Number(s.userId))));
|
||||
const authors = authorIds.length
|
||||
? await prisma.user.findMany({
|
||||
where: { id: { in: authorIds } },
|
||||
select: { id: true, username: true, look: true },
|
||||
})
|
||||
: [];
|
||||
const authorById = new Map(authors.map((a) => [a.id, a]));
|
||||
|
||||
const data = shouts.map((s) => {
|
||||
const author = authorById.get(Number(s.userId));
|
||||
return {
|
||||
id: s.id,
|
||||
userId: s.userId,
|
||||
username: author?.username ?? null,
|
||||
look: author?.look ?? null,
|
||||
message: s.message,
|
||||
createdAt: s.createdAt,
|
||||
};
|
||||
});
|
||||
const data = shouts.map((s) => {
|
||||
const author = authorById.get(Number(s.userId));
|
||||
return {
|
||||
id: s.id,
|
||||
userId: s.userId,
|
||||
username: author?.username ?? null,
|
||||
look: author?.look ?? null,
|
||||
message: s.message,
|
||||
createdAt: s.createdAt,
|
||||
};
|
||||
});
|
||||
|
||||
return apiJson({ shouts: data });
|
||||
} catch {
|
||||
// DB unavailable — serve an empty list rather than a 500.
|
||||
return apiJson({ shouts: [] }, { status: 200 });
|
||||
}
|
||||
return apiJson({ shouts: data });
|
||||
} catch {
|
||||
// DB unavailable — serve an empty list rather than a 500.
|
||||
return apiJson({ shouts: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
// Post a new radio shout as the Bearer-authed user into radio_shouts.
|
||||
export async function POST(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { message?: unknown };
|
||||
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||
const body = (await req.json().catch(() => ({}))) as { message?: unknown };
|
||||
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||
|
||||
if (!message) {
|
||||
return apiError("Message is required", 422);
|
||||
}
|
||||
if (message.length > MAX_MESSAGE_LENGTH) {
|
||||
return apiError(`Message must be at most ${MAX_MESSAGE_LENGTH} characters`, 422);
|
||||
}
|
||||
if (!message) {
|
||||
return apiError("Message is required", 422);
|
||||
}
|
||||
if (message.length > MAX_MESSAGE_LENGTH) {
|
||||
return apiError(
|
||||
`Message must be at most ${MAX_MESSAGE_LENGTH} characters`,
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
await prisma.radioShouts.create({
|
||||
data: {
|
||||
userId: BigInt(uid),
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
try {
|
||||
const now = new Date();
|
||||
await prisma.radioShouts.create({
|
||||
data: {
|
||||
userId: BigInt(uid),
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return apiJson({ ok: true });
|
||||
} catch {
|
||||
// DB write failed — fail soft rather than a 500.
|
||||
return apiError("Could not post shout", 503);
|
||||
}
|
||||
return apiJson({ ok: true });
|
||||
} catch {
|
||||
// DB write failed — fail soft rather than a 500.
|
||||
return apiError("Could not post shout", 503);
|
||||
}
|
||||
}
|
||||
@@ -8,59 +8,63 @@ export const dynamic = "force-dynamic";
|
||||
* updates without polling. Closes cleanly when the client disconnects.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const encoder = new TextEncoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
let closed = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
let closed = false;
|
||||
|
||||
const send = async () => {
|
||||
if (closed) return;
|
||||
const [nowPlaying, listeners] = await Promise.all([
|
||||
fetchNowPlaying().catch(() => null),
|
||||
fetchListeners().catch(() => null),
|
||||
]);
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ nowPlaying, listeners })}\n\n`));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
const send = async () => {
|
||||
if (closed) return;
|
||||
const [nowPlaying, listeners] = await Promise.all([
|
||||
fetchNowPlaying().catch(() => null),
|
||||
fetchListeners().catch(() => null),
|
||||
]);
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ nowPlaying, listeners })}\n\n`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial event immediately, then on an interval.
|
||||
await send();
|
||||
const interval = setInterval(() => void send(), 10_000);
|
||||
// SSE comment as a keep-alive ping between data events.
|
||||
const ping = setInterval(() => {
|
||||
if (!closed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": ping\n\n"));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
}, 25_000);
|
||||
// Initial event immediately, then on an interval.
|
||||
await send();
|
||||
const interval = setInterval(() => void send(), 10_000);
|
||||
// SSE comment as a keep-alive ping between data events.
|
||||
const ping = setInterval(() => {
|
||||
if (!closed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": ping\n\n"));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
}, 25_000);
|
||||
|
||||
const stop = () => {
|
||||
closed = true;
|
||||
clearInterval(interval);
|
||||
clearInterval(ping);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
req.signal.addEventListener("abort", stop);
|
||||
},
|
||||
});
|
||||
const stop = () => {
|
||||
closed = true;
|
||||
clearInterval(interval);
|
||||
clearInterval(ping);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
req.signal.addEventListener("abort", stop);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store, no-transform",
|
||||
connection: "keep-alive",
|
||||
"access-control-allow-origin": "*",
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store, no-transform",
|
||||
connection: "keep-alive",
|
||||
"access-control-allow-origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user