Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.deploy.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ TWITCH_CLIENT_ID=
# Twitch Extension client ID for the panel extension.
TWITCH_EXTENSION_CLIENT_ID=
TWITCH_CLIENT_SECRET=
TWITCH_TOKEN_ENCRYPTION_SECRET=
TWITCH_EVENTSUB_SECRET=
TWITCH_EXTENSION_SECRET=
SESSION_SECRET=
Expand Down
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ TWITCH_CLIENT_ID=
# Twitch Extension client ID for the panel extension.
TWITCH_EXTENSION_CLIENT_ID=
TWITCH_CLIENT_SECRET=
TWITCH_TOKEN_ENCRYPTION_SECRET=
# Generate with `openssl rand -hex 32`
TWITCH_EVENTSUB_SECRET=local-dev-eventsub-secret
# Base64 shared secret from the Twitch Extension developer console
Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ SENTRY_TRACES_SAMPLE_RATE=
TWITCH_CLIENT_ID=
TWITCH_EXTENSION_CLIENT_ID=
TWITCH_CLIENT_SECRET=
TWITCH_TOKEN_ENCRYPTION_SECRET=
TWITCH_EVENTSUB_SECRET=local-dev-eventsub-secret
TWITCH_EXTENSION_SECRET=
SESSION_SECRET=local-dev-session-secret
Expand All @@ -116,6 +117,7 @@ To test Twitch sign-in, bot behavior, and EventSub locally, also set:

- `TWITCH_CLIENT_ID`
- `TWITCH_CLIENT_SECRET`
- `TWITCH_TOKEN_ENCRYPTION_SECRET`
- `ADMIN_TWITCH_USER_IDS`

To test the Twitch panel extension locally, also set:
Expand Down Expand Up @@ -410,6 +412,7 @@ Frontend Worker:
```bash
echo "<TWITCH_CLIENT_ID>" | npx wrangler secret put TWITCH_CLIENT_ID --config wrangler.jsonc
echo "<TWITCH_CLIENT_SECRET>" | npx wrangler secret put TWITCH_CLIENT_SECRET --config wrangler.jsonc
echo "<TWITCH_TOKEN_ENCRYPTION_SECRET>" | npx wrangler secret put TWITCH_TOKEN_ENCRYPTION_SECRET --config wrangler.jsonc
echo "<TWITCH_EVENTSUB_SECRET>" | npx wrangler secret put TWITCH_EVENTSUB_SECRET --config wrangler.jsonc
echo "<TWITCH_EXTENSION_SECRET>" | npx wrangler secret put TWITCH_EXTENSION_SECRET --config wrangler.jsonc
echo "<SESSION_SECRET>" | npx wrangler secret put SESSION_SECRET --config wrangler.jsonc
Expand All @@ -425,10 +428,11 @@ Set these non-secret panel values in `.env.deploy`:
Backend Worker:

```bash
echo "<TWITCH_CLIENT_ID>" | npx wrangler secret put TWITCH_CLIENT_ID --config wrangler.aux.jsonc
echo "<TWITCH_CLIENT_SECRET>" | npx wrangler secret put TWITCH_CLIENT_SECRET --config wrangler.aux.jsonc
echo "<TWITCH_EVENTSUB_SECRET>" | npx wrangler secret put TWITCH_EVENTSUB_SECRET --config wrangler.aux.jsonc
echo "<SENTRY_DSN>" | npx wrangler secret put SENTRY_DSN --config wrangler.aux.jsonc
echo "<TWITCH_CLIENT_ID>" | npx wrangler secret put TWITCH_CLIENT_ID --config wrangler.aux.jsonc
echo "<TWITCH_CLIENT_SECRET>" | npx wrangler secret put TWITCH_CLIENT_SECRET --config wrangler.aux.jsonc
echo "<TWITCH_TOKEN_ENCRYPTION_SECRET>" | npx wrangler secret put TWITCH_TOKEN_ENCRYPTION_SECRET --config wrangler.aux.jsonc
echo "<TWITCH_EVENTSUB_SECRET>" | npx wrangler secret put TWITCH_EVENTSUB_SECRET --config wrangler.aux.jsonc
echo "<SENTRY_DSN>" | npx wrangler secret put SENTRY_DSN --config wrangler.aux.jsonc
```

If the Worker does not exist yet, `wrangler secret put` creates it and uploads the secret.
Expand Down Expand Up @@ -585,6 +589,7 @@ Set these as Codespaces repository secrets or add them to `.env` inside the Code
- `TWITCH_CLIENT_ID`
- `TWITCH_EXTENSION_CLIENT_ID`
- `TWITCH_CLIENT_SECRET`
- `TWITCH_TOKEN_ENCRYPTION_SECRET`
- `TWITCH_EVENTSUB_SECRET`
- `TWITCH_EXTENSION_SECRET`
- `SESSION_SECRET`
Expand Down
2 changes: 1 addition & 1 deletion docs/bot-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ For local development, `TWITCH_BOT_USERNAME` should usually be your dedicated te

`TWITCH_SCOPES` belongs to the broadcaster login flow, not the bot login flow. It should include `channel:bot` so chat replies can use Twitch's bot badge path, plus `channel:read:subscriptions`, `bits:read`, and `channel:manage:redemptions` for VIP token automation and app-owned channel point rewards. If the connected broadcaster account is missing those permissions, reconnect Twitch.

App-owned channel point rewards only work on Twitch Affiliate or Partner channels. If you test this flow on a channel without channel points, Twitch rejects the reward API calls and request-bot leaves the rest of the bot active.
App-owned channel point rewards only work on Twitch Affiliate or Partner channels. If you test this flow on a channel without channel points, Twitch rejects the reward API calls and RockList.Live leaves the rest of the bot active.

2. Make sure your Twitch developer application has both redirect URIs registered:

Expand Down
20 changes: 20 additions & 0 deletions drizzle/0025_vip_token_duration_thresholds.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
ALTER TABLE `channel_settings`
ADD `vip_token_duration_thresholds_json` text DEFAULT '[]' NOT NULL;

ALTER TABLE `playlist_items`
ADD `vip_token_cost` integer DEFAULT 0 NOT NULL;

UPDATE `playlist_items`
SET `vip_token_cost` = CASE
WHEN `request_kind` = 'vip' THEN 1
ELSE 0
END;

ALTER TABLE `played_songs`
ADD `vip_token_cost` integer DEFAULT 0 NOT NULL;

UPDATE `played_songs`
SET `vip_token_cost` = CASE
WHEN `request_kind` = 'vip' THEN 1
ELSE 0
END;
26 changes: 26 additions & 0 deletions drizzle/0026_vip_request_cooldowns.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
ALTER TABLE `channel_settings`
ADD `vip_request_cooldown_enabled` integer DEFAULT 0 NOT NULL;

ALTER TABLE `channel_settings`
ADD `vip_request_cooldown_minutes` integer DEFAULT 0 NOT NULL;

CREATE TABLE `vip_request_cooldowns` (
`channel_id` text NOT NULL,
`normalized_login` text NOT NULL,
`twitch_user_id` text,
`login` text NOT NULL,
`display_name` text,
`source_item_id` text NOT NULL,
`cooldown_started_at` integer NOT NULL,
`cooldown_expires_at` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
PRIMARY KEY(`channel_id`, `normalized_login`),
FOREIGN KEY (`channel_id`) REFERENCES `channels`(`id`) ON UPDATE no action ON DELETE no action
);

CREATE INDEX `vip_request_cooldowns_channel_user_idx`
ON `vip_request_cooldowns` (`channel_id`, `twitch_user_id`);

CREATE INDEX `vip_request_cooldowns_channel_source_idx`
ON `vip_request_cooldowns` (`channel_id`, `source_item_id`);
2 changes: 2 additions & 0 deletions drizzle/0027_show_pick_order_badges.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE channel_settings
ADD COLUMN show_pick_order_badges integer NOT NULL DEFAULT 0;
27 changes: 26 additions & 1 deletion src/components/overlay-settings-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { Checkbox } from "~/components/ui/checkbox";
import { Input } from "~/components/ui/input";
import { useLocaleTranslation } from "~/lib/i18n/client";
import { getPickNumbersForQueuedItems } from "~/lib/pick-order";
import { getErrorMessage, hexToRgba } from "~/lib/utils";
import type { OverlaySettingsInputData } from "~/lib/validation";

Expand Down Expand Up @@ -46,10 +47,21 @@ type PlaylistData = {
requestedByLogin?: string | null;
status: string;
}>;
showPickOrderBadges?: boolean;
};

type ChannelPlaylistPreviewResponse = {
items?: PlaylistData["items"];
playedSongs?: Array<{
requestedByTwitchUserId?: string | null;
requestedByLogin?: string | null;
requestedAt?: number | null;
playedAt?: number | null;
createdAt?: number | null;
}>;
settings?: {
showPickOrderBadges?: boolean;
};
};

const defaultOverlayForm: OverlaySettingsInputData = {
Expand Down Expand Up @@ -135,8 +147,18 @@ export function OverlaySettingsPanel() {
throw new Error(t("overlay.states.failedPreview"));
}

const items = body?.items ?? [];
const pickNumbers = getPickNumbersForQueuedItems(
items,
body?.playedSongs ?? []
);

return {
items: body?.items ?? [],
items: items.map((item, index) => ({
...item,
pickNumber: pickNumbers[index] ?? null,
})),
showPickOrderBadges: !!body?.settings?.showPickOrderBadges,
} satisfies PlaylistData;
},
enabled: !!overlayQuery.data?.channel.slug,
Expand Down Expand Up @@ -536,6 +558,9 @@ export function OverlaySettingsPanel() {
})}
items={previewItems}
theme={previewTheme}
showPickOrderBadges={
playlistQuery.data?.showPickOrderBadges ?? false
}
/>
</div>
</div>
Expand Down
39 changes: 39 additions & 0 deletions src/components/pick-order-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useAppLocale, useLocaleTranslation } from "~/lib/i18n/client";
import { getPickBadgeAppearance, getPickBadgeLabel } from "~/lib/pick-order";
import { cn } from "~/lib/utils";

type PickOrderBadgeVariant = "public" | "overlay" | "panel";

const variantClassNames: Record<PickOrderBadgeVariant, string> = {
public:
"inline-flex items-center border border-transparent px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white",
overlay:
"inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white",
panel:
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[8px] leading-none font-semibold uppercase tracking-[0.12em] text-white",
};

export function PickOrderBadge(props: {
pickNumber: number;
variant?: PickOrderBadgeVariant;
className?: string;
}) {
const { locale } = useAppLocale();
const { t } = useLocaleTranslation("playlist");
const label = getPickBadgeLabel({
locale,
pickNumber: props.pickNumber,
translate: (key, options) => t(key, options),
});
const appearance = getPickBadgeAppearance(props.pickNumber);
const variant = props.variant ?? "public";

return (
<span
className={cn(variantClassNames[variant], props.className)}
style={{ background: appearance.background }}
>
{label}
</span>
);
}
37 changes: 30 additions & 7 deletions src/components/playlist-management-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { AnimatePresence, motion } from "motion/react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { BlacklistPanel } from "~/components/blacklist-panel";
import { DashboardPageHeader } from "~/components/dashboard-page-header";
import { PickOrderBadge } from "~/components/pick-order-badge";
import {
AlertDialog,
AlertDialogAction,
Expand All @@ -58,6 +59,7 @@ import {
formatDate as formatLocaleDate,
formatNumber,
} from "~/lib/i18n/format";
import { getPickNumbersForQueuedItems } from "~/lib/pick-order";
import {
formatPlaylistItemSummaryLine,
getResolvedPlaylistCandidates,
Expand Down Expand Up @@ -101,6 +103,7 @@ export type PlaylistItem = {
warningCode?: string;
warningMessage?: string;
candidateMatchesJson?: string;
pickNumber?: number | null;
createdAt: number;
position: number;
regularPosition?: number | null;
Expand Down Expand Up @@ -238,6 +241,7 @@ type PlaylistQueryData = {
};
settings?: {
canManageBlacklist?: boolean;
showPickOrderBadges?: boolean;
};
items: PlaylistItem[];
playedSongs: PlayedSong[];
Expand Down Expand Up @@ -342,6 +346,7 @@ export function PlaylistManagementSurface(
};
settings?: {
canManageBlacklist?: boolean;
showPickOrderBadges?: boolean;
};
items: PlaylistItem[];
playedSongs: PlayedSong[];
Expand Down Expand Up @@ -647,15 +652,23 @@ export function PlaylistManagementSurface(
},
});

const playedSongs = playlistQuery.data?.playedSongs ?? [];
const showPickOrderBadges =
!!playlistQuery.data?.settings?.showPickOrderBadges;
const items = useMemo(() => {
const baseItems = playlistQuery.data?.items ?? [];
const pickNumbers = getPickNumbersForQueuedItems(baseItems, playedSongs);

return baseItems.map((item, index) => ({
...item,
pickNumber: pickNumbers[index] ?? null,
}));
}, [playedSongs, playlistQuery.data?.items]);
const currentItemId = useMemo(
() =>
playlistQuery.data?.items?.find((item) => item.status === "current")
?.id ?? null,
[playlistQuery.data?.items]
() => items.find((item) => item.status === "current")?.id ?? null,
[items]
);

const items = playlistQuery.data?.items ?? [];
const playedSongs = playlistQuery.data?.playedSongs ?? [];
const vipTokens = playlistQuery.data?.vipTokens ?? [];
const blacklistArtists = playlistQuery.data?.blacklistArtists ?? [];
const blacklistCharters = playlistQuery.data?.blacklistCharters ?? [];
Expand Down Expand Up @@ -1005,6 +1018,7 @@ export function PlaylistManagementSurface(
draggingItemId={draggingItemId}
dropTargetState={dropTargetState}
currentItemId={currentItemId}
showPickOrderBadges={showPickOrderBadges}
canManageBlacklist={canManageBlacklist}
blacklistedArtistIds={blacklistedArtistIds}
blacklistedSongIds={blacklistedSongIds}
Expand Down Expand Up @@ -1200,6 +1214,7 @@ export function PlaylistManagementSurface(
draggingItemId={draggingItemId}
dropTargetState={dropTargetState}
currentItemId={currentItemId}
showPickOrderBadges={showPickOrderBadges}
canManageBlacklist={canManageBlacklist}
blacklistedArtistIds={blacklistedArtistIds}
blacklistedSongIds={blacklistedSongIds}
Expand Down Expand Up @@ -1494,6 +1509,7 @@ function CurrentPlaylistRows(props: {
draggingItemId: string | null;
dropTargetState: { itemId: string; edge: Edge } | null;
currentItemId: string | null;
showPickOrderBadges: boolean;
canManageBlacklist: boolean;
blacklistedArtistIds: Set<number>;
blacklistedSongIds: Set<number>;
Expand Down Expand Up @@ -1549,6 +1565,7 @@ function CurrentPlaylistRows(props: {
draggingItemId={props.draggingItemId}
dropTargetState={props.dropTargetState}
currentItemId={props.currentItemId}
showPickOrderBadges={props.showPickOrderBadges}
isDeletingItem={props.isDeletingItem(item.id)}
isSetCurrentPending={props.isRowPending("setCurrent", item.id)}
isReturnToQueuePending={props.isRowPending(
Expand Down Expand Up @@ -1822,6 +1839,7 @@ function PlaylistQueueItem(props: {
draggingItemId: string | null;
dropTargetState: { itemId: string; edge: Edge } | null;
currentItemId: string | null;
showPickOrderBadges: boolean;
isDeletingItem: boolean;
isSetCurrentPending: boolean;
isReturnToQueuePending: boolean;
Expand Down Expand Up @@ -2006,7 +2024,7 @@ function PlaylistQueueItem(props: {
<div className="flex items-stretch">
{props.useTouchReorderControls ? (
<div className="dashboard-playlist__drag-handle inline-flex w-14 shrink-0 self-stretch border-r border-(--border) px-1 py-2">
<div className="grid w-full grid-cols-2 gap-1">
<div className="grid w-full grid-cols-1 gap-1">
<TouchReorderButton
label={t("management.item.moveTop")}
icon={ChevronsUp}
Expand Down Expand Up @@ -2083,6 +2101,10 @@ function PlaylistQueueItem(props: {
{t("management.item.vipBadge")}
</Badge>
) : null}
{props.showPickOrderBadges &&
props.item.pickNumber != null ? (
<PickOrderBadge pickNumber={props.item.pickNumber} />
) : null}
{isCurrentItem ? (
<Badge className="border-emerald-400/35 bg-emerald-500/15 text-emerald-200 hover:bg-emerald-500/15">
{t("management.item.playingBadge")}
Expand Down Expand Up @@ -2882,6 +2904,7 @@ export function PlaylistQueueItemPreview() {
draggingItemId={null}
dropTargetState={null}
currentItemId={item.status === "current" ? item.id : null}
showPickOrderBadges
isDeletingItem={false}
isSetCurrentPending={false}
isReturnToQueuePending={false}
Expand Down
Loading
Loading