diff --git a/src/lib/eventsub/chat-message.ts b/src/lib/eventsub/chat-message.ts index 52bee10..088a2e3 100644 --- a/src/lib/eventsub/chat-message.ts +++ b/src/lib/eventsub/chat-message.ts @@ -207,6 +207,7 @@ function buildCandidateMatchesJson(results: SongSearchResult[]) { return JSON.stringify( results.slice(0, 5).map((result) => ({ id: result.id, + authorId: result.authorId, title: result.title, artist: result.artist, album: result.album, @@ -223,6 +224,20 @@ function buildCandidateMatchesJson(results: SongSearchResult[]) { ); } +function getRejectedSongMessage(input: { + login: string; + reason?: string; + reasonCode?: string; +}) { + if (input.reasonCode === "charter_blacklist") { + return `${mention(input.login)} this song cannot be played in this channel.`; + } + + return `${mention(input.login)} ${ + input.reason ?? "that song is not allowed in this channel." + }`; +} + function extractRequestedSourceSongId(query: string | undefined) { const match = /^song:(\d+)$/i.exec((query ?? "").trim()); if (!match) { @@ -644,6 +659,13 @@ export async function processEventSubChatMessage(input: { let firstMatch: SongSearchResult | null = null; let candidateMatchesJson: string | undefined; + let firstRejectedMatch: + | { + song: SongSearchResult; + reason?: string; + reasonCode?: string; + } + | undefined; const normalizedQuery = parsed.query?.trim() ?? ""; try { @@ -660,7 +682,31 @@ export async function processEventSubChatMessage(input: { pageSize: 5, }); candidateMatchesJson = buildCandidateMatchesJson(search.results); - firstMatch = search.results[0] ?? null; + for (const result of search.results) { + const songAllowed = isSongAllowed({ + song: result, + settings: state.settings, + blacklistArtists: state.blacklistArtists, + blacklistCharters: state.blacklistCharters, + blacklistSongs: state.blacklistSongs, + setlistArtists: state.setlistArtists, + requester: requesterContext, + }); + + if (songAllowed.allowed) { + firstMatch = result; + break; + } + + if (!firstRejectedMatch) { + firstRejectedMatch = { + song: result, + reason: songAllowed.reason, + reasonCode: + "reasonCode" in songAllowed ? songAllowed.reasonCode : undefined, + }; + } + } } } catch (error) { console.error("EventSub song lookup failed", { @@ -692,6 +738,33 @@ export async function processEventSubChatMessage(input: { let warningMessage: string | undefined; if (!firstMatch) { + if (firstRejectedMatch) { + await deps.createRequestLog(env, { + channelId: channel.id, + twitchMessageId: event.messageId, + twitchUserId: requesterIdentity.twitchUserId, + requesterLogin: requesterIdentity.login, + requesterDisplayName: requesterIdentity.displayName, + rawMessage: event.rawMessage, + normalizedQuery: parsed.query, + matchedSongId: firstRejectedMatch.song.id, + matchedSongTitle: firstRejectedMatch.song.title, + matchedSongArtist: firstRejectedMatch.song.artist, + outcome: "rejected", + outcomeReason: firstRejectedMatch.reason, + }); + await deps.sendChatReply(env, { + channelId: channel.id, + broadcasterUserId: channel.twitchChannelId, + message: getRejectedSongMessage({ + login: requesterIdentity.login, + reason: firstRejectedMatch.reason, + reasonCode: firstRejectedMatch.reasonCode, + }), + }); + return { body: "Rejected", status: 202 }; + } + warningCode = "no_song_match"; warningMessage = `No matching track found for "${unmatchedQuery}".`; } @@ -725,7 +798,12 @@ export async function processEventSubChatMessage(input: { await deps.sendChatReply(env, { channelId: channel.id, broadcasterUserId: channel.twitchChannelId, - message: `${mention(requesterIdentity.login)} ${songAllowed.reason ?? "that song is not allowed in this channel."}`, + message: getRejectedSongMessage({ + login: requesterIdentity.login, + reason: songAllowed.reason, + reasonCode: + "reasonCode" in songAllowed ? songAllowed.reasonCode : undefined, + }), }); return { body: "Rejected", status: 202 }; } diff --git a/src/lib/playlist/types.ts b/src/lib/playlist/types.ts index ff29072..9924c49 100644 --- a/src/lib/playlist/types.ts +++ b/src/lib/playlist/types.ts @@ -9,6 +9,7 @@ export interface AddRequestInput { song: { id: string; title: string; + authorId?: number; artist?: string; album?: string; creator?: string; @@ -97,6 +98,7 @@ export interface ManualAddInput { song: { id: string; title: string; + authorId?: number; artist?: string; album?: string; creator?: string; diff --git a/src/lib/request-policy.ts b/src/lib/request-policy.ts index dfd2a4d..8e764f9 100644 --- a/src/lib/request-policy.ts +++ b/src/lib/request-policy.ts @@ -193,6 +193,7 @@ export function isSongAllowed(input: { return { allowed: false, reason: "That song's tuning is not allowed in this channel.", + reasonCode: "disallowed_tuning", }; } } @@ -205,6 +206,7 @@ export function isSongAllowed(input: { return { allowed: false, reason: "That artist is not in the current setlist.", + reasonCode: "artist_not_in_setlist", }; } } @@ -230,6 +232,7 @@ export function isSongAllowed(input: { reason: charterBlocked ? `${charterBlocked.charterName} is blacklisted in this channel.` : "That song is blocked in this channel.", + reasonCode: charterBlocked ? "charter_blacklist" : "song_blacklist", }; } } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 167b673..5aa4278 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -263,6 +263,7 @@ export const playlistMutationSchema = z.discriminatedUnion("action", [ songId: z.string(), requesterLogin: z.string().trim().min(2).max(25).optional(), title: z.string().min(1), + authorId: z.number().optional(), artist: z.string().optional(), album: z.string().optional(), creator: z.string().optional(), @@ -272,5 +273,6 @@ export const playlistMutationSchema = z.discriminatedUnion("action", [ source: z.string(), sourceUrl: z.string().optional(), sourceId: z.number().optional(), + candidateMatchesJson: z.string().optional(), }), ]); diff --git a/src/routes/dashboard/playlist.tsx b/src/routes/dashboard/playlist.tsx index f95ebb7..5b1c53f 100644 --- a/src/routes/dashboard/playlist.tsx +++ b/src/routes/dashboard/playlist.tsx @@ -59,6 +59,7 @@ type PlaylistItem = { type PlaylistCandidate = { id: string; + authorId?: number; title: string; artist?: string; album?: string; @@ -88,6 +89,7 @@ type ManualSearchData = Pick; type SearchResponse = { results: Array<{ id: string; + authorId?: number; title: string; artist?: string; album?: string; @@ -431,6 +433,9 @@ function DashboardPlaylistPage() { const blacklistArtists = playlistQuery.data?.blacklistArtists ?? []; const blacklistCharters = playlistQuery.data?.blacklistCharters ?? []; const blacklistSongs = playlistQuery.data?.blacklistSongs ?? []; + const blacklistedCharterIds = new Set( + blacklistCharters.map((item) => item.charterId) + ); const managedChannel = playlistQuery.data?.channel ?? null; const accessRole = playlistQuery.data?.accessRole ?? "owner"; const isDeletingItem = (itemId: string) => @@ -519,72 +524,106 @@ function DashboardPlaylistPage() {
Tuning / Path
Add
- {manualSearchQuery.data?.results?.map((song, index) => ( -
-
-

- {song.title} -

-

- {song.artist ?? "Unknown artist"} -

-
-
-

- {song.album ?? "Unknown album"} -

-

- {song.creator - ? `Charted by ${song.creator}` - : "Unknown creator"} -

-
-
-

- {song.tuning ?? "No tuning info"} -

-

- {song.parts?.length - ? song.parts.join(", ") - : "No path info"} -

-
-
- + {manualSearchQuery.data?.results?.map((song, index) => { + const isBlacklistedCharter = + song.authorId != null && + blacklistedCharterIds.has(song.authorId); + + return ( +
+
+

+ {song.title} +

+

+ {song.artist ?? "Unknown artist"} +

+
+
+

+ {song.album ?? "Unknown album"} +

+
+ + {song.creator + ? `Charted by ${song.creator}` + : "Unknown creator"} + + {isBlacklistedCharter ? ( + + Blacklisted + + ) : null} +
+
+
+

+ {song.tuning ?? "No tuning info"} +

+

+ {song.parts?.length + ? song.parts.join(", ") + : "No path info"} +

+
+
+ +
-
- ))} + ); + })}
) : null} @@ -658,6 +697,7 @@ function DashboardPlaylistPage() { isDeletingItem={isDeletingItem(item.id)} isSetCurrentPending={isRowPending("setCurrent", item.id)} isMarkPlayedPending={isRowPending("markPlayed", item.id)} + blacklistedCharterIds={blacklistedCharterIds} onDragStart={setDraggingItemId} onDragEnd={() => { setDraggingItemId(null); @@ -954,6 +994,7 @@ function PlaylistQueueItem(props: { isDeletingItem: boolean; isSetCurrentPending: boolean; isMarkPlayedPending: boolean; + blacklistedCharterIds: Set; onDragStart: (itemId: string) => void; onDragEnd: () => void; onDragHover: (targetItemId: string, edge: Edge) => void; @@ -1186,87 +1227,102 @@ function PlaylistQueueItem(props: {
- {resolvedCandidates.map((candidate, candidateIndex) => ( -
-
-
-

- {candidate.title} -

- {candidate.album ? ( -

- {candidate.album} + {resolvedCandidates.map((candidate, candidateIndex) => { + const isBlacklistedCharter = + candidate.authorId != null && + props.blacklistedCharterIds.has(candidate.authorId); + + return ( +

+
+
+

+ {candidate.title}

+ {candidate.album ? ( +

+ {candidate.album} +

+ ) : null} +
+ + Charted by {candidate.creator ?? "Unknown"} + + {isBlacklistedCharter ? ( + + Blacklisted + + ) : null} +
+
+ {candidate.sourceUrl ? ( + ) : null}
- {candidate.sourceUrl ? ( - - ) : null} -
-
- - formatPathLabel(part)) - .join(", ") - : "Unknown" - } - /> - - - - +
+ + formatPathLabel(part)) + .join(", ") + : "Unknown" + } + /> + + + +
-
- ))} + ); + })}
diff --git a/src/workers/backend/index.ts b/src/workers/backend/index.ts index e62705a..c658e6f 100644 --- a/src/workers/backend/index.ts +++ b/src/workers/backend/index.ts @@ -4,6 +4,7 @@ import { drizzle } from "drizzle-orm/d1"; import { createPlayedSong, getBotAuthorization, + getChannelBlacklistByChannelId, updateTwitchAuthorizationTokens, } from "~/lib/db/repositories"; import * as schema from "~/lib/db/schema"; @@ -35,7 +36,7 @@ import type { ShuffleNextInput, ShufflePlaylistInput, } from "~/lib/playlist/types"; -import { getRequiredPathsWarning } from "~/lib/request-policy"; +import { getRequiredPathsWarning, isSongAllowed } from "~/lib/request-policy"; import { getSentryD1Database, getSentryOptions } from "~/lib/sentry"; import { getAppAccessToken, @@ -65,6 +66,7 @@ type MutationPayload = Record; type PlaylistCandidate = { id: string; + authorId?: number; title: string; artist?: string; album?: string; @@ -289,6 +291,7 @@ class D1PlaylistCoordinator implements PlaylistCoordinator { } async manualAdd(input: ManualAddInput): Promise { + const db = getDb(this.env); const channel = await getDb(this.env).query.channels.findFirst({ where: eq(channels.id, input.channelId), }); @@ -301,6 +304,51 @@ class D1PlaylistCoordinator implements PlaylistCoordinator { ? await resolveTwitchUserForRequester(this.env, input.requesterLogin) : null; + const settings = await db.query.channelSettings.findFirst({ + where: eq(channelSettings.channelId, input.channelId), + }); + const blacklist = await getChannelBlacklistByChannelId( + this.env as unknown as never, + input.channelId + ); + + if (!settings) { + throw new Error("Channel settings not found"); + } + + const songAllowed = isSongAllowed({ + song: { + id: input.song.id, + artistId: undefined, + authorId: input.song.authorId, + title: input.song.title, + artist: input.song.artist, + album: input.song.album, + creator: input.song.creator, + tuning: input.song.tuning, + parts: input.song.parts, + durationText: input.song.durationText, + sourceId: input.song.cdlcId, + source: input.song.source, + sourceUrl: input.song.sourceUrl, + }, + settings, + blacklistArtists: blacklist.blacklistArtists, + blacklistCharters: blacklist.blacklistCharters, + blacklistSongs: blacklist.blacklistSongs, + setlistArtists: [], + requester: { + isBroadcaster: true, + isModerator: false, + isVip: false, + isSubscriber: false, + }, + }); + + if (!songAllowed.allowed) { + throw new Error(songAllowed.reason ?? "That song is not allowed."); + } + return this.addRequest({ channelId: input.channelId, requestedByTwitchUserId: requester?.id ?? channel.twitchChannelId, @@ -828,6 +876,11 @@ class D1PlaylistCoordinator implements PlaylistCoordinator { where: eq(channelSettings.channelId, input.channelId), }); + const blacklist = await getChannelBlacklistByChannelId( + this.env as unknown as never, + input.channelId + ); + if (!settings) { throw new Error("Channel settings not found"); } @@ -849,6 +902,38 @@ class D1PlaylistCoordinator implements PlaylistCoordinator { throw new Error("Candidate version not found"); } + const songAllowed = isSongAllowed({ + song: { + id: candidate.id, + authorId: candidate.authorId, + title: candidate.title, + artist: candidate.artist, + album: candidate.album, + creator: candidate.creator, + tuning: candidate.tuning, + parts: candidate.parts, + durationText: candidate.durationText, + sourceId: candidate.sourceId, + source: "library", + sourceUrl: candidate.sourceUrl, + }, + settings, + blacklistArtists: blacklist.blacklistArtists, + blacklistCharters: blacklist.blacklistCharters, + blacklistSongs: blacklist.blacklistSongs, + setlistArtists: [], + requester: { + isBroadcaster: false, + isModerator: false, + isVip: false, + isSubscriber: false, + }, + }); + + if (!songAllowed.allowed) { + throw new Error(songAllowed.reason ?? "That version is not allowed."); + } + const warningMessage = getRequiredPathsWarning({ song: { parts: candidate.parts ?? [] }, settings, diff --git a/tests/eventsub.chat-message.test.ts b/tests/eventsub.chat-message.test.ts index 8acf17a..5c6fdf2 100644 --- a/tests/eventsub.chat-message.test.ts +++ b/tests/eventsub.chat-message.test.ts @@ -517,7 +517,130 @@ describe("processEventSubChatMessage", () => { expect(deps.sendChatReply).toHaveBeenCalledWith( env, expect.objectContaining({ - message: "@viewer_one charter is blacklisted in this channel.", + message: "@viewer_one this song cannot be played in this channel.", + }) + ); + }); + + it("falls through to the first allowed search candidate when an earlier charter is blacklisted", async () => { + const deps = createDeps({ + getDashboardState: vi.fn().mockResolvedValue({ + ...createState({ + blacklistEnabled: true, + }), + blacklistCharters: [{ charterId: 101, charterName: "charter" }], + }), + searchSongs: vi.fn().mockResolvedValue({ + results: [ + createSong({ + id: "song-blocked", + authorId: 101, + creator: "charter", + sourceId: 11111, + }), + createSong({ + id: "song-allowed", + authorId: 202, + creator: "other-charter", + sourceId: 22222, + }), + ], + }), + }); + + const result = await processEventSubChatMessage({ + env, + event: createEvent({ + rawMessage: "!sr cherub rock", + }), + parsed: createParsed({ + query: "cherub rock", + }), + deps, + }); + + expect(result).toEqual({ + body: "Accepted", + status: 202, + }); + expect(deps.addRequestToPlaylist).toHaveBeenCalledWith( + env, + expect.objectContaining({ + song: expect.objectContaining({ + id: "song-allowed", + cdlcId: 22222, + title: "Cherub Rock", + creator: "other-charter", + candidateMatchesJson: expect.any(String), + }), + }) + ); + + const addCall = vi.mocked(deps.addRequestToPlaylist).mock.calls[0]?.[1]; + const candidates = JSON.parse( + addCall?.song.candidateMatchesJson ?? "[]" + ) as Array<{ id: string; authorId?: number }>; + expect(candidates.map((candidate) => candidate.id)).toEqual([ + "song-blocked", + "song-allowed", + ]); + expect(candidates.map((candidate) => candidate.authorId)).toEqual([ + 101, 202, + ]); + expect(deps.sendChatReply).toHaveBeenCalledWith( + env, + expect.objectContaining({ + message: expect.stringContaining( + 'your song "The Smashing Pumpkins - Cherub Rock" has been added to the playlist.' + ), + }) + ); + }); + + it("rejects search requests when every matched version is by a blacklisted charter", async () => { + const deps = createDeps({ + getDashboardState: vi.fn().mockResolvedValue({ + ...createState({ + blacklistEnabled: true, + }), + blacklistCharters: [{ charterId: 101, charterName: "charter" }], + }), + searchSongs: vi.fn().mockResolvedValue({ + results: [ + createSong({ + id: "song-blocked-1", + authorId: 101, + sourceId: 11111, + }), + createSong({ + id: "song-blocked-2", + authorId: 101, + sourceId: 22222, + }), + ], + }), + }); + + const result = await processEventSubChatMessage({ + env, + event: createEvent({ + rawMessage: "!sr cherub rock", + }), + parsed: createParsed({ + query: "cherub rock", + }), + deps, + }); + + expect(result).toEqual({ + body: "Rejected", + status: 202, + }); + expect(deps.addRequestToPlaylist).not.toHaveBeenCalled(); + expect(deps.sendChatReply).toHaveBeenCalledWith( + env, + expect.objectContaining({ + message: "@viewer_one this song cannot be played in this channel.", }) ); }); @@ -685,10 +808,13 @@ describe("processEventSubChatMessage", () => { const addCall = vi.mocked(deps.addRequestToPlaylist).mock.calls[0]?.[1]; const candidates = JSON.parse( addCall?.song.candidateMatchesJson ?? "[]" - ) as Array<{ id: string }>; + ) as Array<{ id: string; authorId?: number }>; expect(candidates.map((candidate) => candidate.id)).toEqual([ "song-1", "song-2", ]); + expect(candidates.map((candidate) => candidate.authorId)).toEqual([ + 101, 101, + ]); }); }); diff --git a/tests/requests.test.ts b/tests/requests.test.ts index 397f00c..7986422 100644 --- a/tests/requests.test.ts +++ b/tests/requests.test.ts @@ -193,6 +193,7 @@ describe("request policy", () => { ).toEqual({ allowed: false, reason: "That song is blocked in this channel.", + reasonCode: "song_blacklist", }); expect( @@ -225,6 +226,7 @@ describe("request policy", () => { ).toEqual({ allowed: false, reason: "That song is blocked in this channel.", + reasonCode: "song_blacklist", }); }); @@ -259,6 +261,7 @@ describe("request policy", () => { ).toEqual({ allowed: false, reason: "Charter Name is blacklisted in this channel.", + reasonCode: "charter_blacklist", }); }); });