From 53d9dfec607c822e618622c4201499bc1a23cb8f Mon Sep 17 00:00:00 2001 From: James Keezer <125431058+Jamesllllllllll@users.noreply.github.com> Date: Sat, 21 Mar 2026 16:50:25 -0400 Subject: [PATCH] Add charter blacklisting --- drizzle/0008_blacklisted_charters.sql | 8 + src/components/blacklist-panel.tsx | 29 +++- src/lib/db/latest-migration.generated.ts | 2 +- src/lib/db/repositories.ts | 94 +++++++++++- src/lib/db/schema.ts | 16 ++ src/lib/eventsub/chat-message.ts | 4 + src/lib/request-policy.ts | 26 +++- src/lib/song-search/types.ts | 1 + src/lib/validation.ts | 9 ++ src/routes/$slug/index.tsx | 5 +- .../api/channel/$slug/playlist/route.ts | 1 + src/routes/api/dashboard/moderation.ts | 17 +++ src/routes/api/dashboard/moderation/search.ts | 15 +- src/routes/api/dashboard/playlist/route.ts | 2 + src/routes/channel/$slug.tsx | 5 +- src/routes/dashboard/moderation.tsx | 138 +++++++++++++++++- src/routes/dashboard/playlist.tsx | 9 +- tests/eventsub.chat-message.test.ts | 35 ++++- tests/requests.test.ts | 37 +++++ 19 files changed, 434 insertions(+), 19 deletions(-) create mode 100644 drizzle/0008_blacklisted_charters.sql diff --git a/drizzle/0008_blacklisted_charters.sql b/drizzle/0008_blacklisted_charters.sql new file mode 100644 index 0000000..103566e --- /dev/null +++ b/drizzle/0008_blacklisted_charters.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS `blacklisted_charters` ( + `channel_id` text NOT NULL, + `charter_id` integer NOT NULL, + `charter_name` text NOT NULL, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + PRIMARY KEY(`channel_id`, `charter_id`), + FOREIGN KEY (`channel_id`) REFERENCES `channels`(`id`) ON UPDATE no action ON DELETE no action +); diff --git a/src/components/blacklist-panel.tsx b/src/components/blacklist-panel.tsx index 1047c53..9906ab3 100644 --- a/src/components/blacklist-panel.tsx +++ b/src/components/blacklist-panel.tsx @@ -12,16 +12,22 @@ export type BlacklistedSongItem = { artistName?: string | null; }; +export type BlacklistedCharterItem = { + charterId: number; + charterName: string; +}; + export function BlacklistPanel(props: { title?: string; description?: string; artists: BlacklistedArtistItem[]; + charters?: BlacklistedCharterItem[]; songs: BlacklistedSongItem[]; collapsible?: boolean; defaultOpen?: boolean; }) { const content = ( - +
{props.artists.length > 0 ? (
@@ -43,6 +49,27 @@ export function BlacklistPanel(props: { )}
+
+ {(props.charters ?? []).length > 0 ? ( +
+ {(props.charters ?? []).map((charter, index) => ( +
+

+ {charter.charterName} ({charter.charterId}) +

+
+ ))} +
+ ) : ( +

No blacklisted charters.

+ )} +
+
{props.songs.length > 0 ? (
diff --git a/src/lib/db/latest-migration.generated.ts b/src/lib/db/latest-migration.generated.ts index fbd0cc6..c2d72f8 100644 --- a/src/lib/db/latest-migration.generated.ts +++ b/src/lib/db/latest-migration.generated.ts @@ -1 +1 @@ -export const LATEST_MIGRATION_NAME = "0007_setlist_artist_ids.sql"; +export const LATEST_MIGRATION_NAME = "0008_blacklisted_charters.sql"; diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index 9981cfd..931e1ea 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -20,8 +20,10 @@ import { type AuditLogInsert, auditLogs, type BlacklistedArtistInsert, + type BlacklistedCharterInsert, type BlacklistedSongInsert, blacklistedArtists, + blacklistedCharters, blacklistedSongs, blockedUsers, type CatalogSongInsert, @@ -329,6 +331,7 @@ export async function getDashboardState(env: AppEnv, ownerUserId: string) { audits, blacklistArtistsRows, blacklistSongsRows, + blacklistCharterRows, setlistArtistRows, vipTokenRows, playedRows, @@ -368,6 +371,10 @@ export async function getDashboardState(env: AppEnv, ownerUserId: string) { asc(blacklistedSongs.artistName), ], }), + db.query.blacklistedCharters.findMany({ + where: eq(blacklistedCharters.channelId, channel.id), + orderBy: [asc(blacklistedCharters.charterName)], + }), db.query.setlistArtists.findMany({ where: eq(setlistArtists.channelId, channel.id), orderBy: [asc(setlistArtists.artistName)], @@ -394,6 +401,7 @@ export async function getDashboardState(env: AppEnv, ownerUserId: string) { logs, audits, blacklistArtists: blacklistArtistsRows, + blacklistCharters: blacklistCharterRows, blacklistSongs: blacklistSongsRows, setlistArtists: setlistArtistRows, vipTokens: vipTokenRows, @@ -472,11 +480,15 @@ export async function getChannelBlacklistByChannelId( env: AppEnv, channelId: string ) { - const [artistRows, songRows] = await Promise.all([ + const [artistRows, charterRows, songRows] = await Promise.all([ getDb(env).query.blacklistedArtists.findMany({ where: eq(blacklistedArtists.channelId, channelId), orderBy: [asc(blacklistedArtists.artistName)], }), + getDb(env).query.blacklistedCharters.findMany({ + where: eq(blacklistedCharters.channelId, channelId), + orderBy: [asc(blacklistedCharters.charterName)], + }), getDb(env).query.blacklistedSongs.findMany({ where: eq(blacklistedSongs.channelId, channelId), orderBy: [ @@ -488,6 +500,7 @@ export async function getChannelBlacklistByChannelId( return { blacklistArtists: artistRows, + blacklistCharters: charterRows, blacklistSongs: songRows, }; } @@ -1155,6 +1168,7 @@ export async function searchCatalogSongs( id: string; sourceSongId: number; artistId: number | null; + authorId: number | null; title: string; artistName: string; albumName: string | null; @@ -1178,10 +1192,11 @@ export async function searchCatalogSongs( } ) SELECT - catalog_songs.id, - catalog_songs.source_song_id AS sourceSongId, - catalog_songs.artist_id AS artistId, - catalog_songs.title, + catalog_songs.id, + catalog_songs.source_song_id AS sourceSongId, + catalog_songs.artist_id AS artistId, + catalog_songs.author_id AS authorId, + catalog_songs.title, catalog_songs.artist_name AS artistName, catalog_songs.album_name AS albumName, catalog_songs.creator_name AS creatorName, @@ -1209,6 +1224,7 @@ export async function searchCatalogSongs( results: resultRows.map((row) => ({ id: row.id, artistId: row.artistId ?? undefined, + authorId: row.authorId ?? undefined, title: decodeHtmlEntities(row.title), artist: decodeHtmlEntities(row.artistName), album: row.albumName ? decodeHtmlEntities(row.albumName) : undefined, @@ -1253,6 +1269,7 @@ export async function getCatalogSongBySourceId( return { id: row.id, artistId: row.artistId ?? undefined, + authorId: row.authorId ?? undefined, title: decodeHtmlEntities(row.title), artist: decodeHtmlEntities(row.artistName), album: row.albumName ? decodeHtmlEntities(row.albumName) : undefined, @@ -1358,6 +1375,48 @@ export async function searchCatalogSongsForBlacklist( })); } +export async function searchCatalogChartersForBlacklist( + env: AppEnv, + input: { + query: string; + limit?: number; + } +) { + const normalizedQuery = escapeLikeValue(input.query.toLowerCase()); + if (normalizedQuery.length < 2) { + return []; + } + + const rows = await getDb(env).all<{ + charterId: number; + charterName: string; + trackCount: number; + }>(sql` + SELECT + author_id AS charterId, + creator_name AS charterName, + COUNT(*) AS trackCount + FROM catalog_songs + WHERE author_id IS NOT NULL + AND trim(coalesce(creator_name, '')) != '' + AND lower(creator_name) LIKE ${`%${normalizedQuery}%`} + GROUP BY author_id, creator_name + ORDER BY + CASE + WHEN lower(creator_name) LIKE ${`${normalizedQuery}%`} THEN 0 + ELSE 1 + END, + creator_name ASC + LIMIT ${Math.min(Math.max(input.limit ?? 8, 1), 25)} + `); + + return unwrapD1Rows(rows).map((row) => ({ + charterId: row.charterId, + charterName: decodeHtmlEntities(row.charterName), + trackCount: row.trackCount, + })); +} + export async function getCatalogSongsByIds(env: AppEnv, songIds: string[]) { const uniqueIds = [...new Set(songIds.filter(Boolean))]; if (uniqueIds.length === 0) { @@ -1967,6 +2026,16 @@ export async function addBlacklistedSong( await getDb(env).insert(blacklistedSongs).values(input).onConflictDoNothing(); } +export async function addBlacklistedCharter( + env: AppEnv, + input: Omit +) { + await getDb(env) + .insert(blacklistedCharters) + .values(input) + .onConflictDoNothing(); +} + export async function removeBlacklistedSong( env: AppEnv, channelId: string, @@ -1982,6 +2051,21 @@ export async function removeBlacklistedSong( ); } +export async function removeBlacklistedCharter( + env: AppEnv, + channelId: string, + charterId: number +) { + await getDb(env) + .delete(blacklistedCharters) + .where( + and( + eq(blacklistedCharters.channelId, channelId), + eq(blacklistedCharters.charterId, charterId) + ) + ); +} + export async function addSetlistArtist( env: AppEnv, input: Omit diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 55211c5..f93af3a 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -318,6 +318,21 @@ export const blacklistedSongs = sqliteTable( (table) => [primaryKey({ columns: [table.channelId, table.songId] })] ); +export const blacklistedCharters = sqliteTable( + "blacklisted_charters", + { + channelId: text("channel_id") + .notNull() + .references(() => channels.id), + charterId: integer("charter_id").notNull(), + charterName: text("charter_name").notNull(), + createdAt: integer("created_at") + .notNull() + .default(sql`(unixepoch() * 1000)`), + }, + (table) => [primaryKey({ columns: [table.channelId, table.charterId] })] +); + export const setlistArtists = sqliteTable( "setlist_artists", { @@ -663,6 +678,7 @@ export type PlaylistItemInsert = typeof playlistItems.$inferInsert; export type BlockedUserInsert = typeof blockedUsers.$inferInsert; export type BlacklistedArtistInsert = typeof blacklistedArtists.$inferInsert; export type BlacklistedSongInsert = typeof blacklistedSongs.$inferInsert; +export type BlacklistedCharterInsert = typeof blacklistedCharters.$inferInsert; export type SetlistArtistInsert = typeof setlistArtists.$inferInsert; export type VipTokenInsert = typeof vipTokens.$inferInsert; export type RequestLogInsert = typeof requestLogs.$inferInsert; diff --git a/src/lib/eventsub/chat-message.ts b/src/lib/eventsub/chat-message.ts index b379d54..52bee10 100644 --- a/src/lib/eventsub/chat-message.ts +++ b/src/lib/eventsub/chat-message.ts @@ -48,6 +48,7 @@ export interface EventSubChatSettings { export interface EventSubChatState { settings: EventSubChatSettings & Parameters[0]; blacklistArtists: Array<{ artistId: number; artistName: string }>; + blacklistCharters: Array<{ charterId: number; charterName: string }>; blacklistSongs: Array<{ songId: number; songTitle: string; @@ -440,6 +441,7 @@ export async function processEventSubChatMessage(input: { commandPrefix: state.settings.commandPrefix, appUrl: env.APP_URL, blacklistArtists: state.blacklistArtists, + blacklistCharters: state.blacklistCharters, blacklistSongs: state.blacklistSongs, setlistArtists: state.setlistArtists, }), @@ -462,6 +464,7 @@ export async function processEventSubChatMessage(input: { broadcasterUserId: channel.twitchChannelId, message: buildBlacklistMessage( state.blacklistArtists, + state.blacklistCharters, state.blacklistSongs ), }); @@ -698,6 +701,7 @@ export async function processEventSubChatMessage(input: { song: firstMatch, settings: state.settings, blacklistArtists: state.blacklistArtists, + blacklistCharters: state.blacklistCharters, blacklistSongs: state.blacklistSongs, setlistArtists: state.setlistArtists, requester: requesterContext, diff --git a/src/lib/request-policy.ts b/src/lib/request-policy.ts index cd415df..dfd2a4d 100644 --- a/src/lib/request-policy.ts +++ b/src/lib/request-policy.ts @@ -159,6 +159,7 @@ export function isSongAllowed(input: { song: SongSearchResult; settings: ChannelRequestSettings; blacklistArtists: Array<{ artistId: number; artistName: string }>; + blacklistCharters: Array<{ charterId: number; charterName: string }>; blacklistSongs: Array<{ songId: number; songTitle: string; @@ -213,16 +214,22 @@ export function isSongAllowed(input: { (entry) => input.song.artistId != null && entry.artistId === input.song.artistId ); + const charterBlocked = input.blacklistCharters.find( + (entry) => + input.song.authorId != null && entry.charterId === input.song.authorId + ); const songBlocked = input.blacklistSongs.some( (entry) => input.song.sourceId != null && entry.songId === input.song.sourceId ); const bypass = input.settings.letSetlistBypassBlacklist && inSetlist; - if (!bypass && (artistBlocked || songBlocked)) { + if (!bypass && (artistBlocked || charterBlocked || songBlocked)) { return { allowed: false, - reason: "That song is blocked in this channel.", + reason: charterBlocked + ? `${charterBlocked.charterName} is blacklisted in this channel.` + : "That song is blocked in this channel.", }; } } @@ -312,6 +319,7 @@ export function buildHowMessage(input: { commandPrefix: string; appUrl: string; blacklistArtists: Array<{ artistId?: number; artistName: string }>; + blacklistCharters: Array<{ charterId?: number; charterName: string }>; blacklistSongs: Array<{ songId?: number; songTitle: string; @@ -328,6 +336,7 @@ export function buildHowMessage(input: { parts.push( `${normalized}blacklist: ${buildBlacklistMessage( input.blacklistArtists, + input.blacklistCharters, input.blacklistSongs )}` ); @@ -352,14 +361,15 @@ export function buildSearchMessage(appUrl: string) { export function buildBlacklistMessage( artists: Array<{ artistId?: number; artistName: string }>, + charters: Array<{ charterId?: number; charterName: string }>, songs: Array<{ songId?: number; songTitle: string; artistName?: string | null; }> ) { - if (artists.length === 0 && songs.length === 0) { - return "No blacklisted artists or songs."; + if (artists.length === 0 && charters.length === 0 && songs.length === 0) { + return "No blacklisted artists, charters, or songs."; } const artistText = artists.length @@ -368,6 +378,12 @@ export function buildBlacklistMessage( .map((entry) => entry.artistName) .join(", ") : "none"; + const charterText = charters.length + ? charters + .slice(0, 5) + .map((entry) => entry.charterName) + .join(", ") + : "none"; const songText = songs.length ? songs .slice(0, 5) @@ -378,7 +394,7 @@ export function buildBlacklistMessage( ) .join(", ") : "none"; - return `Artists: ${artistText}. Songs: ${songText}.`; + return `Artists: ${artistText}. Charters: ${charterText}. Songs: ${songText}.`; } export function buildSetlistMessage( diff --git a/src/lib/song-search/types.ts b/src/lib/song-search/types.ts index 36c82db..59cecb0 100644 --- a/src/lib/song-search/types.ts +++ b/src/lib/song-search/types.ts @@ -1,6 +1,7 @@ export type SongSearchResult = { id: string; artistId?: number; + authorId?: number; title: string; artist?: string; album?: string; diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 5109f89..167b673 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -94,6 +94,15 @@ export const moderationActionSchema = z.discriminatedUnion("action", [ action: z.literal("removeBlacklistedArtist"), artistId: z.number().int().positive(), }), + z.object({ + action: z.literal("addBlacklistedCharter"), + charterId: z.number().int().positive(), + charterName: z.string().trim().min(1).max(200), + }), + z.object({ + action: z.literal("removeBlacklistedCharter"), + charterId: z.number().int().positive(), + }), z.object({ action: z.literal("addBlacklistedSong"), songId: z.number().int().positive(), diff --git a/src/routes/$slug/index.tsx b/src/routes/$slug/index.tsx index fd8213c..85a0c31 100644 --- a/src/routes/$slug/index.tsx +++ b/src/routes/$slug/index.tsx @@ -45,6 +45,7 @@ type PublicChannelPageData = { items?: EnrichedPublicPlaylistItem[]; playedSongs?: PlayedSongRow[]; blacklistArtists?: Array<{ artistId: number; artistName: string }>; + blacklistCharters?: Array<{ charterId: number; charterName: string }>; blacklistSongs?: Array<{ songId: number; songTitle: string; @@ -95,6 +96,7 @@ function PublicChannelPage() { items?: PublicPlaylistItem[]; playedSongs?: PlayedSongRow[]; blacklistArtists?: PublicChannelPageData["playlist"]["blacklistArtists"]; + blacklistCharters?: PublicChannelPageData["playlist"]["blacklistCharters"]; blacklistSongs?: PublicChannelPageData["playlist"]["blacklistSongs"]; }; @@ -179,8 +181,9 @@ function PublicChannelPage() { diff --git a/src/routes/api/channel/$slug/playlist/route.ts b/src/routes/api/channel/$slug/playlist/route.ts index d180636..7c73175 100644 --- a/src/routes/api/channel/$slug/playlist/route.ts +++ b/src/routes/api/channel/$slug/playlist/route.ts @@ -37,6 +37,7 @@ export const Route = createFileRoute("/api/channel/$slug/playlist")({ items: playlist?.items ?? [], playedSongs: playedRows, blacklistArtists: blacklist.blacklistArtists, + blacklistCharters: blacklist.blacklistCharters, blacklistSongs: blacklist.blacklistSongs, }); }, diff --git a/src/routes/api/dashboard/moderation.ts b/src/routes/api/dashboard/moderation.ts index 027dee3..0deb875 100644 --- a/src/routes/api/dashboard/moderation.ts +++ b/src/routes/api/dashboard/moderation.ts @@ -4,6 +4,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { getSessionUserId } from "~/lib/auth/session.server"; import { addBlacklistedArtist, + addBlacklistedCharter, addBlacklistedSong, addBlockedUser, addSetlistArtist, @@ -11,6 +12,7 @@ import { getDashboardState, grantVipToken, removeBlacklistedArtist, + removeBlacklistedCharter, removeBlacklistedSong, removeSetlistArtist, revokeVipToken, @@ -44,6 +46,7 @@ export const Route = createFileRoute("/api/dashboard/moderation")({ }, blocks: state.blocks, blacklistArtists: state.blacklistArtists, + blacklistCharters: state.blacklistCharters, blacklistSongs: state.blacklistSongs, setlistArtists: state.setlistArtists, vipTokens: state.vipTokens, @@ -92,6 +95,20 @@ export const Route = createFileRoute("/api/dashboard/moderation")({ body.artistId ); break; + case "addBlacklistedCharter": + await addBlacklistedCharter(runtimeEnv, { + channelId: state.channel.id, + charterId: body.charterId, + charterName: body.charterName, + }); + break; + case "removeBlacklistedCharter": + await removeBlacklistedCharter( + runtimeEnv, + state.channel.id, + body.charterId + ); + break; case "addBlacklistedSong": await addBlacklistedSong(runtimeEnv, { channelId: state.channel.id, diff --git a/src/routes/api/dashboard/moderation/search.ts b/src/routes/api/dashboard/moderation/search.ts index 502c52f..39f9cad 100644 --- a/src/routes/api/dashboard/moderation/search.ts +++ b/src/routes/api/dashboard/moderation/search.ts @@ -4,6 +4,7 @@ import { getSessionUserId } from "~/lib/auth/session.server"; import { getDashboardState, searchCatalogArtistsForBlacklist, + searchCatalogChartersForBlacklist, searchCatalogSongsForBlacklist, } from "~/lib/db/repositories"; import type { AppEnv } from "~/lib/env"; @@ -35,6 +36,7 @@ export const Route = createFileRoute("/api/dashboard/moderation/search")({ if (query.length < 2) { return json({ artists: [], + charters: [], songs: [], }); } @@ -47,6 +49,14 @@ export const Route = createFileRoute("/api/dashboard/moderation/search")({ }); } + if (type === "charter") { + return json({ + charters: await searchCatalogChartersForBlacklist(runtimeEnv, { + query, + }), + }); + } + if (type === "song") { return json({ songs: await searchCatalogSongsForBlacklist(runtimeEnv, { @@ -55,12 +65,13 @@ export const Route = createFileRoute("/api/dashboard/moderation/search")({ }); } - const [artists, songs] = await Promise.all([ + const [artists, charters, songs] = await Promise.all([ searchCatalogArtistsForBlacklist(runtimeEnv, { query }), + searchCatalogChartersForBlacklist(runtimeEnv, { query }), searchCatalogSongsForBlacklist(runtimeEnv, { query }), ]); - return json({ artists, songs }); + return json({ artists, charters, songs }); }, }, }, diff --git a/src/routes/api/dashboard/playlist/route.ts b/src/routes/api/dashboard/playlist/route.ts index 7195ffc..f0070fb 100644 --- a/src/routes/api/dashboard/playlist/route.ts +++ b/src/routes/api/dashboard/playlist/route.ts @@ -84,6 +84,7 @@ async function requireDashboardState( items: playlistState.items, playedSongs: playedRows, blacklistArtists: blacklist.blacklistArtists, + blacklistCharters: blacklist.blacklistCharters, blacklistSongs: blacklist.blacklistSongs, accessRole: access.accessRole, actorUserId: access.actorUserId, @@ -139,6 +140,7 @@ export const Route = createFileRoute("/api/dashboard/playlist")({ items: await enrichPlaylistItems(runtimeEnv, state.items), playedSongs: state.playedSongs, blacklistArtists: state.blacklistArtists, + blacklistCharters: state.blacklistCharters, blacklistSongs: state.blacklistSongs, accessRole: state.accessRole, requiredPaths: state.settings diff --git a/src/routes/channel/$slug.tsx b/src/routes/channel/$slug.tsx index e077ac0..a23ba09 100644 --- a/src/routes/channel/$slug.tsx +++ b/src/routes/channel/$slug.tsx @@ -43,6 +43,7 @@ type ChannelPageData = { items?: EnrichedChannelPlaylistItem[]; playedSongs?: PlayedSongRow[]; blacklistArtists?: Array<{ artistId: number; artistName: string }>; + blacklistCharters?: Array<{ charterId: number; charterName: string }>; blacklistSongs?: Array<{ songId: number; songTitle: string; @@ -93,6 +94,7 @@ function ChannelPage() { items?: ChannelPlaylistItem[]; playedSongs?: PlayedSongRow[]; blacklistArtists?: ChannelPageData["playlist"]["blacklistArtists"]; + blacklistCharters?: ChannelPageData["playlist"]["blacklistCharters"]; blacklistSongs?: ChannelPageData["playlist"]["blacklistSongs"]; }; @@ -163,8 +165,9 @@ function ChannelPage() { diff --git a/src/routes/dashboard/moderation.tsx b/src/routes/dashboard/moderation.tsx index 8ce4ccb..8194089 100644 --- a/src/routes/dashboard/moderation.tsx +++ b/src/routes/dashboard/moderation.tsx @@ -39,6 +39,12 @@ type ArtistMatch = { trackCount: number; }; +type CharterMatch = { + charterId: number; + charterName: string; + trackCount: number; +}; + type SongMatch = { songId: number; songTitle: string; @@ -56,6 +62,7 @@ type ModerationData = { reason?: string; }>; blacklistArtists: Array<{ artistId: number; artistName: string }>; + blacklistCharters: Array<{ charterId: number; charterName: string }>; blacklistSongs: Array<{ songId: number; songTitle: string; @@ -83,6 +90,8 @@ function DashboardModerationPage() { const queryClient = useQueryClient(); const [artistQuery, setArtistQuery] = useState(""); const [debouncedArtistQuery, setDebouncedArtistQuery] = useState(""); + const [charterQuery, setCharterQuery] = useState(""); + const [debouncedCharterQuery, setDebouncedCharterQuery] = useState(""); const [songQuery, setSongQuery] = useState(""); const [debouncedSongQuery, setDebouncedSongQuery] = useState(""); const [setlistArtistQuery, setSetlistArtistQuery] = useState(""); @@ -93,6 +102,7 @@ function DashboardModerationPage() { useEffect(() => { const timeout = window.setTimeout(() => { setDebouncedArtistQuery(artistQuery.trim()); + setDebouncedCharterQuery(charterQuery.trim()); setDebouncedSongQuery(songQuery.trim()); setDebouncedSetlistArtistQuery(setlistArtistQuery.trim()); }, 400); @@ -100,7 +110,7 @@ function DashboardModerationPage() { return () => { window.clearTimeout(timeout); }; - }, [artistQuery, setlistArtistQuery, songQuery]); + }, [artistQuery, charterQuery, setlistArtistQuery, songQuery]); const { data } = useQuery({ queryKey: ["dashboard-moderation"], @@ -136,6 +146,19 @@ function DashboardModerationPage() { enabled: debouncedSongQuery.length >= 2, }); + const charterSearchQuery = useQuery({ + queryKey: ["dashboard-moderation-charter-search", debouncedCharterQuery], + queryFn: async () => { + const response = await fetch( + `/api/dashboard/moderation/search?type=charter&query=${encodeURIComponent( + debouncedCharterQuery + )}` + ); + return response.json() as Promise<{ charters: CharterMatch[] }>; + }, + enabled: debouncedCharterQuery.length >= 2, + }); + const setlistArtistSearchQuery = useQuery({ queryKey: [ "dashboard-moderation-setlist-artist-search", @@ -178,6 +201,9 @@ function DashboardModerationPage() { const blacklistedArtistIds = new Set( (data?.blacklistArtists ?? []).map((item) => item.artistId) ); + const blacklistedCharterIds = new Set( + (data?.blacklistCharters ?? []).map((item) => item.charterId) + ); const blacklistedSongIds = new Set( (data?.blacklistSongs ?? []).map((item) => item.songId) ); @@ -187,6 +213,9 @@ function DashboardModerationPage() { const visibleSongMatches = (songSearchQuery.data?.songs ?? []).filter( (song) => !blacklistedSongIds.has(song.songId) ); + const visibleCharterMatches = ( + charterSearchQuery.data?.charters ?? [] + ).filter((charter) => !blacklistedCharterIds.has(charter.charterId)); const setlistArtistIds = new Set( (data?.setlistArtists ?? []).map((item) => item.artistId) ); @@ -349,6 +378,113 @@ function DashboardModerationPage() { + + +
+ Blacklisted charters + {!blacklistEnabled ? ( + Disabled + ) : null} +
+
+ + setCharterQuery(event.target.value)} + placeholder="Search charters by name" + disabled={!blacklistEnabled} + /> + {blacklistEnabled && + debouncedCharterQuery.length > 0 && + debouncedCharterQuery.length < 2 ? ( +

+ Type at least 2 characters to search. +

+ ) : null} + {blacklistEnabled && debouncedCharterQuery.length >= 2 ? ( +
+ {visibleCharterMatches.map((charter) => ( +
+
+

+ {charter.charterName} +

+

+ Charter ID {charter.charterId} ยท {charter.trackCount}{" "} + tracks +

+
+ +
+ ))} + {charterSearchQuery.data && + visibleCharterMatches.length === 0 ? ( +

+ {charterSearchQuery.data.charters.length === 0 + ? "No matching charters." + : "All matching charters are already blacklisted."} +

+ ) : null} +
+ ) : null} +
+ {data?.blacklistCharters?.length ? ( +
+ {data.blacklistCharters.map((item) => ( +
+
+

+ {item.charterName} +

+

+ Charter ID {item.charterId} +

+
+ +
+ ))} +
+ ) : ( +

+ No blacklisted charters. +

+ )} +
+
+
+
diff --git a/src/routes/dashboard/playlist.tsx b/src/routes/dashboard/playlist.tsx index 1e1be0d..f95ebb7 100644 --- a/src/routes/dashboard/playlist.tsx +++ b/src/routes/dashboard/playlist.tsx @@ -124,6 +124,7 @@ type PlaylistQueryData = { items: PlaylistItem[]; playedSongs: PlayedSong[]; blacklistArtists: Array<{ artistId: number; artistName: string }>; + blacklistCharters: Array<{ charterId: number; charterName: string }>; blacklistSongs: Array<{ songId: number; songTitle: string; @@ -167,6 +168,10 @@ function DashboardPlaylistPage() { items: PlaylistItem[]; playedSongs: PlayedSong[]; blacklistArtists: Array<{ artistId: number; artistName: string }>; + blacklistCharters: Array<{ + charterId: number; + charterName: string; + }>; blacklistSongs: Array<{ songId: number; songTitle: string; @@ -424,6 +429,7 @@ function DashboardPlaylistPage() { const items = playlistQuery.data?.items ?? []; const playedSongs = playlistQuery.data?.playedSongs ?? []; const blacklistArtists = playlistQuery.data?.blacklistArtists ?? []; + const blacklistCharters = playlistQuery.data?.blacklistCharters ?? []; const blacklistSongs = playlistQuery.data?.blacklistSongs ?? []; const managedChannel = playlistQuery.data?.channel ?? null; const accessRole = playlistQuery.data?.accessRole ?? "owner"; @@ -703,8 +709,9 @@ function DashboardPlaylistPage() { diff --git a/tests/eventsub.chat-message.test.ts b/tests/eventsub.chat-message.test.ts index 4b19228..8acf17a 100644 --- a/tests/eventsub.chat-message.test.ts +++ b/tests/eventsub.chat-message.test.ts @@ -43,6 +43,7 @@ function createSong( return { id: "song-1", artistId: 77, + authorId: 101, title: "Cherub Rock", artist: "The Smashing Pumpkins", album: "Siamese Dream", @@ -89,6 +90,7 @@ function createState(overrides: Record = {}) { ...overrides, }, blacklistArtists: [], + blacklistCharters: [], blacklistSongs: [], setlistArtists: [], logs: [], @@ -459,6 +461,7 @@ describe("processEventSubChatMessage", () => { getDashboardState: vi.fn().mockResolvedValue({ ...createState(), blacklistArtists: [{ artistName: "Chevelle" }], + blacklistCharters: [{ charterName: "Frif" }], blacklistSongs: [{ songTitle: "The Red" }], setlistArtists: [{ artistName: "Smashing Pumpkins" }], }), @@ -484,7 +487,37 @@ describe("processEventSubChatMessage", () => { env, expect.objectContaining({ message: - "Commands: !sr artist, song; !sr song; !vip artist, song; !edit artist, song; !remove reg; !remove vip; !remove all. !blacklist: Artists: Chevelle. Songs: The Red. !setlist: Artists: Smashing Pumpkins. Search for songs to request: https://example.com/search", + "Commands: !sr artist, song; !sr song; !vip artist, song; !edit artist, song; !remove reg; !remove vip; !remove all. !blacklist: Artists: Chevelle. Charters: Frif. Songs: The Red. !setlist: Artists: Smashing Pumpkins. Search for songs to request: https://example.com/search", + }) + ); + }); + + it("rejects songs when the matched charter is blacklisted", async () => { + const deps = createDeps({ + getDashboardState: vi.fn().mockResolvedValue({ + ...createState({ + blacklistEnabled: true, + }), + blacklistCharters: [{ charterId: 101, charterName: "charter" }], + }), + }); + + const result = await processEventSubChatMessage({ + env, + event: createEvent(), + parsed: createParsed(), + deps, + }); + + expect(result).toEqual({ + body: "Rejected", + status: 202, + }); + expect(deps.addRequestToPlaylist).not.toHaveBeenCalled(); + expect(deps.sendChatReply).toHaveBeenCalledWith( + env, + expect.objectContaining({ + message: "@viewer_one charter is blacklisted in this channel.", }) ); }); diff --git a/tests/requests.test.ts b/tests/requests.test.ts index ae22b3c..397f00c 100644 --- a/tests/requests.test.ts +++ b/tests/requests.test.ts @@ -101,6 +101,7 @@ describe("request policy", () => { commandPrefix: "!sr", appUrl: "https://example.com", blacklistArtists: [], + blacklistCharters: [], blacklistSongs: [], setlistArtists: [], }); @@ -179,6 +180,7 @@ describe("request policy", () => { blacklistEnabled: true, }, blacklistArtists: [{ artistId: 777, artistName: "David Bowie" }], + blacklistCharters: [], blacklistSongs: [], setlistArtists: [], requester: { @@ -208,6 +210,7 @@ describe("request policy", () => { blacklistEnabled: true, }, blacklistArtists: [], + blacklistCharters: [], blacklistSongs: [ { songId: 67890, songTitle: "Heroes", artistName: "Other Artist" }, ], @@ -224,4 +227,38 @@ describe("request policy", () => { reason: "That song is blocked in this channel.", }); }); + + it("blocks charter matches by exact charter ID", () => { + expect( + isSongAllowed({ + song: { + id: "song-3", + sourceId: 24680, + artistId: 999, + authorId: 555, + title: "Song", + artist: "Artist", + creator: "Charter Name", + source: "library", + }, + settings: { + ...baseSettings, + blacklistEnabled: true, + }, + blacklistArtists: [], + blacklistCharters: [{ charterId: 555, charterName: "Charter Name" }], + blacklistSongs: [], + setlistArtists: [], + requester: { + isBroadcaster: false, + isModerator: false, + isVip: false, + isSubscriber: false, + }, + }) + ).toEqual({ + allowed: false, + reason: "Charter Name is blacklisted in this channel.", + }); + }); });