External streams

This commit is contained in:
Kalle
2026-06-16 15:46:04 +03:00
parent c141591269
commit a56a0fa0fe
42 changed files with 378 additions and 8 deletions

View File

@@ -1229,6 +1229,15 @@ export interface TournamentStreamer {
twitchAccount: string;
}
export interface ExternalStream {
id: GeneratedAlways<number>;
name: string;
url: string;
avatarImgId: number | null;
startTime: number;
createdAt: Generated<number>;
}
export interface TournamentMatchVod {
id: GeneratedAlways<number>;
matchId: number;
@@ -1473,6 +1482,7 @@ export interface DB {
CalendarEventDate: CalendarEventDate;
CalendarEventResultPlayer: CalendarEventResultPlayer;
CalendarEventResultTeam: CalendarEventResultTeam;
ExternalStream: ExternalStream;
Group: Group;
GroupLike: GroupLike;

View File

@@ -0,0 +1,80 @@
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import { databaseTimestampNow } from "~/utils/dates";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
/** Number of seconds an external stream keeps showing in the sidebar after its start time. */
const SIDEBAR_VISIBLE_SECONDS = 6 * 60 * 60;
/** Number of seconds after the start time before an external stream row is deleted. */
const RETENTION_SECONDS = 24 * 60 * 60;
/** Inserts a new admin-curated external stream. */
export function insert(
args: Pick<
TablesInsertable["ExternalStream"],
"name" | "url" | "avatarImgId" | "startTime"
>,
) {
return db.insertInto("ExternalStream").values(args).execute();
}
/** Deletes an external stream by its id. */
export function deleteById(id: number) {
return db.deleteFrom("ExternalStream").where("id", "=", id).execute();
}
/** Lists all external streams (for the admin management page), soonest start time first. */
export function all() {
return db
.selectFrom("ExternalStream")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"ExternalStream.avatarImgId",
)
.select((eb) => [
"ExternalStream.id",
"ExternalStream.name",
"ExternalStream.url",
"ExternalStream.startTime",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
),
])
.orderBy("ExternalStream.startTime", "asc")
.execute();
}
/** External streams that should currently show in the sidebar (started under 6h ago or upcoming). */
export function forSidebar() {
return db
.selectFrom("ExternalStream")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"ExternalStream.avatarImgId",
)
.select((eb) => [
"ExternalStream.id",
"ExternalStream.name",
"ExternalStream.url",
"ExternalStream.startTime",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
),
])
.where(
"ExternalStream.startTime",
">=",
databaseTimestampNow() - SIDEBAR_VISIBLE_SECONDS,
)
.execute();
}
/** Deletes external streams whose start time is more than 24h in the past. */
export function deleteOld() {
return db
.deleteFrom("ExternalStream")
.where("startTime", "<", databaseTimestampNow() - RETENTION_SECONDS)
.executeTakeFirst();
}

View File

@@ -0,0 +1,46 @@
import type { ActionFunctionArgs } from "react-router";
import { clearCombinedStreamsCache } from "~/features/core/streams/streams.server";
import { parseFormDataWithImages } from "~/form/parse.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { assertUnreachable } from "~/utils/types";
import { externalStreamActionSchema } from "../admin-schemas";
import * as ExternalStreamRepository from "../ExternalStreamRepository.server";
export const action = async ({ request }: ActionFunctionArgs) => {
requireRole("ADMIN");
const result = await parseFormDataWithImages({
request,
schema: externalStreamActionSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
switch (data._action) {
case "CREATE": {
await ExternalStreamRepository.insert({
name: data.name,
url: data.url,
avatarImgId: data.avatar,
startTime: dateToDatabaseTimestamp(data.startTime),
});
break;
}
case "DELETE": {
await ExternalStreamRepository.deleteById(data.id);
break;
}
default: {
assertUnreachable(data);
}
}
clearCombinedStreamsCache();
return null;
};

View File

@@ -1,6 +1,34 @@
import { z } from "zod";
import { friendCode } from "~/utils/zod";
import {
datetimeRequired,
image,
stringConstant,
textFieldRequired,
} from "~/form/fields";
import { friendCode, id } from "~/utils/zod";
export const adminActionSearchParamsSchema = z.object({
friendCode,
});
export const createExternalStreamSchema = z.object({
_action: stringConstant("CREATE"),
name: textFieldRequired({ label: "labels.name", maxLength: 64 }),
url: textFieldRequired({
label: "labels.link",
maxLength: 200,
validate: "url",
}),
avatar: image({ label: "labels.logo", autoValidate: true }),
startTime: datetimeRequired({ label: "labels.startTime" }),
});
const deleteExternalStreamSchema = z.object({
_action: stringConstant("DELETE"),
id,
});
export const externalStreamActionSchema = z.union([
createExternalStreamSchema,
deleteExternalStreamSchema,
]);

View File

@@ -0,0 +1,10 @@
import { requireRole } from "~/modules/permissions/guards.server";
import * as ExternalStreamRepository from "../ExternalStreamRepository.server";
export const loader = async () => {
requireRole("ADMIN");
return {
streams: await ExternalStreamRepository.all(),
};
};

View File

@@ -0,0 +1,12 @@
.streamRow {
display: flex;
align-items: center;
gap: var(--s-2);
}
.streamAvatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 100%;
object-fit: cover;
}

View File

@@ -0,0 +1,98 @@
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import { SendouForm } from "~/form/SendouForm";
import { metaTags } from "~/utils/remix";
import { action } from "../actions/admin.streams.server";
import { createExternalStreamSchema } from "../admin-schemas";
import { loader } from "../loaders/admin.streams.server";
import styles from "./admin.streams.module.css";
export { action, loader };
export const meta: MetaFunction = (args) => {
return metaTags({
title: "External streams",
location: args.location,
});
};
export default function AdminStreamsPage() {
return (
<Main className="stack lg">
<SendouForm
schema={createExternalStreamSchema}
title="Add external stream"
>
{({ FormField }) => (
<>
<FormField name="name" />
<FormField name="url" />
<FormField name="avatar" />
<FormField name="startTime" />
</>
)}
</SendouForm>
<ExternalStreamList />
</Main>
);
}
function ExternalStreamList() {
const { streams } = useLoaderData<typeof loader>();
if (streams.length === 0) {
return <div className="text-lighter">No external streams</div>;
}
return (
<div className="stack md">
<h2>Current external streams</h2>
<ul className="stack sm">
{streams.map((stream) => (
<li key={stream.id} className={styles.streamRow}>
{stream.avatarUrl ? (
<img
src={stream.avatarUrl}
alt=""
className={styles.streamAvatar}
/>
) : null}
<div className="stack xxs">
<Link to={stream.url} className="text-main-forced">
{stream.name}
</Link>
<span className="text-xs text-lighter">
<LocaleTime
date={stream.startTime}
inline
options={{
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}}
/>
</span>
</div>
<FormWithConfirm
dialogHeading={`Delete external stream "${stream.name}"?`}
fields={[
["_action", "DELETE"],
["id", stream.id],
]}
>
<SendouButton variant="minimal-destructive" className="ml-auto">
Delete
</SendouButton>
</FormWithConfirm>
</li>
))}
</ul>
</div>
);
}

View File

@@ -4,6 +4,12 @@ import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
type RankedStream = { stream: SidebarStream; score: number };
/**
* Score for admin-curated external streams. Below every other source's minimum (0) so they sort
* to the top of the ranking, reserving the first sidebar slots.
*/
export const EXTERNAL_STREAM_SCORE = -1;
export function rank(
streams: RankedStream[],
maxStreams: number,

View File

@@ -2,6 +2,7 @@ import { cachified } from "@epic-web/cachified";
import { addDays } from "date-fns";
import { href } from "react-router";
import * as R from "remeda";
import * as ExternalStreamRepository from "~/features/admin/ExternalStreamRepository.server";
import { userIsBanned } from "~/features/ban/core/banned.server";
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
import {
@@ -137,11 +138,13 @@ function combinedStreamsCached(): Promise<SidebarStream[]> {
async function combinedStreams(): Promise<SidebarStream[]> {
const tournamentStreams = getLiveTournamentStreams();
const [sendouQEntries, xRankRows, upcomingTournaments] = await Promise.all([
getSendouQSidebarStreams(),
LiveStreamRepository.findXRankStreams(),
ShowcaseTournaments.upcomingTournaments(),
]);
const [sendouQEntries, xRankRows, upcomingTournaments, externalStreams] =
await Promise.all([
getSendouQSidebarStreams(),
LiveStreamRepository.findXRankStreams(),
ShowcaseTournaments.upcomingTournaments(),
ExternalStreamRepository.forSidebar(),
]);
const seenUsernames = new Set([
...getLiveTournamentStreamerTwitchNames(),
@@ -152,6 +155,21 @@ async function combinedStreams(): Promise<SidebarStream[]> {
const ranked: { stream: SidebarStream; score: number }[] = [];
for (const externalStream of externalStreams) {
ranked.push({
stream: {
id: `external-${externalStream.id}`,
name: externalStream.name,
imageUrl: externalStream.avatarUrl ?? BLANK_IMAGE_URL,
url: externalStream.url,
subtitle: "",
startsAt: externalStream.startTime,
tier: null,
},
score: StreamRanking.EXTERNAL_STREAM_SCORE,
});
}
for (const stream of tournamentStreams) {
ranked.push({
stream,

View File

@@ -268,6 +268,7 @@ export default [
]),
route("/admin", "features/admin/routes/admin.tsx"),
route("/admin/streams", "features/admin/routes/admin.streams.tsx"),
route("/api/chat-users", "features/chat/routes/api.chat-users.ts"),
route("/room", "features/chat/routes/room.ts"),
route("/api", "features/api/routes/api.tsx"),

View File

@@ -0,0 +1,11 @@
import * as ExternalStreamRepository from "../features/admin/ExternalStreamRepository.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
export const DeleteOldExternalStreamsRoutine = new Routine({
name: "DeleteOldExternalStreams",
func: async () => {
const { numDeletedRows } = await ExternalStreamRepository.deleteOld();
logger.info(`Deleted ${numDeletedRows} old external streams`);
},
});

View File

@@ -1,6 +1,7 @@
import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions";
import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes";
import { DeleteObsoleteMatchVodsRoutine } from "./deleteObsoleteMatchVods";
import { DeleteOldExternalStreamsRoutine } from "./deleteOldExternalStreams";
import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
import { DeleteOldRoomLinksRoutine } from "./deleteOldRoomLinks";
import { DeleteOldTournamentAuditLogsRoutine } from "./deleteOldTournamentAuditLogs";
@@ -32,6 +33,7 @@ export const everyHourAt30 = [
UpdatePatreonDataRoutine,
CloseExpiredContinueVotesRoutine,
DeleteOldRoomLinksRoutine,
DeleteOldExternalStreamsRoutine,
];
/** List of Routines that should occur daily */

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -293,8 +293,21 @@ async function selectMapWinner(page: Page, winner: "ALPHA" | "BRAVO") {
page.locator('[data-testid^="winner-radio-"][data-selected="true"]'),
).toHaveCount(0);
// react-aria's Radio renders a hidden input behind a span overlay; click the
// wrapping label so the press handler fires and updates winnerId.
await page.locator(`label:has(input[aria-label="${teamName}"])`).click();
// wrapping label so the press handler fires and updates winnerId. The press
// occasionally registers a press-start without a press-end (same React Aria
// nondeterminism as in waitForPOSTResponse), so the selection silently drops
// and Submit stays disabled. Re-issue the click until the radio reports
// selected; otherwise the Submit-click retry loop spins on a disabled button.
const label = page.locator(`label:has(input[aria-label="${teamName}"])`);
const radio = page.locator(
`[data-testid^="winner-radio-"]:has(input[aria-label="${teamName}"])`,
);
await expect(async () => {
await label.click();
await expect(radio).toHaveAttribute("data-selected", "true", {
timeout: 1_000,
});
}).toPass();
}
async function voteNo(page: Page) {

View File

@@ -4,6 +4,7 @@
"labels.bio": "Biografi",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",

View File

@@ -4,6 +4,7 @@
"labels.bio": "Über mich",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",

View File

@@ -4,6 +4,7 @@
"labels.bio": "Bio",
"labels.logo": "Logo",
"labels.banner": "Banner",
"labels.link": "Link",
"labels.tag": "Tag",
"labels.teamBsky": "Team Bluesky",
"labels.teamEditor": "Editor",

View File

@@ -4,6 +4,7 @@
"labels.bio": "Biografía",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "Etiqueta",
"labels.teamBsky": "Bluesky del equipo",
"labels.teamEditor": "Editor",

View File

@@ -4,6 +4,7 @@
"labels.bio": "Biografía",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",

View File

@@ -4,6 +4,7 @@
"labels.bio": "Bio",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",

View File

@@ -4,6 +4,7 @@
"labels.bio": "Bio",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "Team Bluesky",
"labels.teamEditor": "Editer",

View File

@@ -4,6 +4,7 @@
"labels.bio": "ביו",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",

View File

@@ -4,6 +4,7 @@
"labels.bio": "Biografia",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "Bluesky del team",
"labels.teamEditor": "Editor",

View File

@@ -4,6 +4,7 @@
"labels.bio": "自己紹介",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "チームの Bluesky",
"labels.teamEditor": "",

View File

@@ -4,6 +4,7 @@
"labels.bio": "소개",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",

View File

@@ -4,6 +4,7 @@
"labels.bio": "Bio",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",

View File

@@ -4,6 +4,7 @@
"labels.bio": "Opis",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",

View File

@@ -4,6 +4,7 @@
"labels.bio": "Bio",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",

View File

@@ -4,6 +4,7 @@
"labels.bio": "Описание",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "Bluesky команды",
"labels.teamEditor": "Редактор",

View File

@@ -4,6 +4,7 @@
"labels.bio": "简介",
"labels.logo": "",
"labels.banner": "",
"labels.link": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.teamEditor": "",

View File

@@ -0,0 +1,19 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `
create table "ExternalStream" (
"id" integer primary key autoincrement,
"name" text not null,
"url" text not null,
"avatarImgId" integer,
"startTime" integer not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("avatarImgId") references "UnvalidatedUserSubmittedImage"("id") on delete set null
) strict
`,
).run();
db.pragma("foreign_key_check");
})();
}