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
6 changes: 6 additions & 0 deletions server/src/defaults/achievements/achievements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ export const getDefaultAchievementsData = (stagesId: string, tagId: string): Ach
stages: stagesId,
tag: tagId
},
{
name: ACHIEVEMENTS.ADDED_NEW_SONG_TO_DB,
description: "Achievements for added NEW songs to my list",
stages: stagesId,
tag: tagId
},
{
name: ACHIEVEMENTS.SONG_REQUEST,
description: "Achievements for song requests in message",
Expand Down
1 change: 1 addition & 0 deletions server/src/defaults/achievements/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export enum ACHIEVEMENTS {
RECEIVED_SUBSCRIBTIONS_GIFTS = "SUB GIFTS RECEIVED",
BOUGHT_SUBSCRIBTIONS = "BOUGHT SUBS",
COMMANDS_COUNT = "COMMANDS",
ADDED_NEW_SONG_TO_DB = "FILL SONGS DATABASE",
SONG_REQUEST = "MY MUSIC",
SONG_VOTING = "MY MUSIC TASTE",
BADGES_COUNT = "OBTAINED BADGES"
Expand Down
7 changes: 4 additions & 3 deletions server/src/services/songs/songsService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { checkExistResource, AppError, handleAppError, logger } from "@utils";
import { FilterQuery, UpdateQuery } from "mongoose";
import {
CreateSongReturn,
ManageSongLikesAction,
ManySongsFindOptions,
SongsCreateData,
Expand Down Expand Up @@ -32,9 +33,9 @@ export const getSongsCount = async (filter: FilterQuery<SongsDocument> = {}) =>
return await Songs.countDocuments(filter);
};

export const createSong = async (createData: SongsCreateData) => {
export const createSong = async (createData: SongsCreateData): Promise<CreateSongReturn | undefined> => {
const foundSong = await getOneSong({ youtubeId: createData.youtubeId }, {});
if (foundSong) return foundSong;
if (foundSong) return { isNew: false, song: foundSong };

const { whoAdded, ...rest } = createData;
try {
Expand All @@ -46,7 +47,7 @@ export const createSong = async (createData: SongsCreateData) => {
if (!createdSong) {
throw new AppError(400, "Couldn't create new song(s");
}
return createdSong;
return { isNew: true, song: createdSong };
} catch (err) {
logger.error(`Error occured while creating song(s). ${err}`);
handleAppError(err);
Expand Down
2 changes: 2 additions & 0 deletions server/src/services/songs/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ export interface SongsUpdateData
Partial<Pick<SongsModel, "enabled">> {}

export type ManageSongLikesAction = "like" | "dislike" | "nothing";

export type CreateSongReturn = { isNew: boolean; song: SongsModel };
8 changes: 8 additions & 0 deletions server/src/stream/AchievementsHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,14 @@ class AchievementsHandler extends QueueHandler<
});
}

public async incrementAddNewSongToDatabaseAchievements(args: CommonAchievementCheckType) {
await this.updateAchievementUserProgressAndAddToQueue({
achievementName: ACHIEVEMENTS.ADDED_NEW_SONG_TO_DB,
...args,
progress: { value: 1, increment: true }
});
}

private async checkCustomMessageAchievements(data: CheckMessageForAchievement) {
const foundCustomMessageAchievements = await getAchievements(
{ enabled: true, custom: { $exists: true }, isTime: false },
Expand Down
25 changes: 23 additions & 2 deletions server/src/stream/EventSubHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import { alertSoundsPath, alertSoundPrefix } from "@configs";
import path from "path";
import { AuthorizedUserData } from "./types";
import AchievementsHandler from "./AchievementsHandler";
import MusicYTHandler from "./MusicYTHandler";

interface EventSubHandlerOptions {
apiClient: ApiClient;
socketIO: Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
authorizedUser: AuthorizedUserData;
achievementsHandler: AchievementsHandler;
musicYTHandler: MusicYTHandler;
}

export type SubscriptionTiers = "1000" | "2000" | "3000";
Expand All @@ -25,12 +27,14 @@ class EventSubHandler extends HeadHandler {
private redemptionQue: [RewardData, { audioBuffer: Buffer; duration: number }][] = [];
private isAlertPlaying = false;
private achievementsHandler: AchievementsHandler;
private musicYTHandler: MusicYTHandler;
constructor(options: EventSubHandlerOptions) {
super(options.socketIO, options.apiClient, options.authorizedUser);
this.listener = new EventSubWsListener({
apiClient: options.apiClient
});
this.achievementsHandler = options.achievementsHandler;
this.musicYTHandler = options.musicYTHandler;
}

public async updateOptions(options: EventSubHandlerOptions): Promise<void> {
Expand Down Expand Up @@ -268,8 +272,17 @@ class EventSubHandler extends HeadHandler {

private async subscribeToChannelRedemptionAddEvents() {
this.listener.onChannelRedemptionAdd(this.authorizedUser.id, async (e) => {
const { rewardId, userId, userName, userDisplayName, redemptionDate, rewardTitle, rewardCost } = e;

const {
rewardId,
userId,
userName,
userDisplayName,
redemptionDate,
rewardTitle,
rewardCost,
input,
updateStatus
} = e;
const user = await createUserIfNotExist(
{ twitchId: userId },
{ twitchId: userId, username: userDisplayName, twitchName: userName }
Expand Down Expand Up @@ -300,6 +313,14 @@ class EventSubHandler extends HeadHandler {
const alertSoundPath = path.join(alertSoundsPath, rewardTitle) + ".mp3";
let alertSoundFileBuffer: Buffer;

await this.musicYTHandler.handleIfSongRequestRewardIsRedeemed({
title: rewardTitle,
input,
username: userDisplayName,
updateStatus: updateStatus.bind(e)
});

// manage alert sounds
if (!rewardTitle.startsWith(alertSoundPrefix)) return;

try {
Expand Down
123 changes: 102 additions & 21 deletions server/src/stream/MusicYTHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import type {
AudioYTData,
AudioDataRequester
} from "@socket";
import { MusicConfigs, UserDocument } from "@models";
import { MusicConfigs, UserDocument, UserModel } from "@models";
import moment from "moment";
import MusicHeadHandler from "./MusicHeadHandler";
import { shuffleArray, convertSecondsToMS, isValidUrl } from "@utils";
import { SongProperties } from "./types";
import { AuthorizedUserData, SongProperties } from "./types";
import YoutubeApiHandler from "./YoutubeAPIHandler";
import { botId } from "@configs";
import {
Expand All @@ -23,13 +23,24 @@ import {
manageSongLikesByYoutubeId,
updateSongs
} from "@services";
import { ApiClient, HelixCustomRewardRedemption, HelixCustomRewardRedemptionTargetStatus } from "@twurple/api";
import AchievementsHandler from "./AchievementsHandler";
import { CreateSongReturn } from "services/songs/types";

interface PlaylistDetails {
id: string;
name: string;
count: number;
}

const SONG_REQUEST_REWARD_NAME = "Song Request";

interface HandleIfSongRequestRewardIsRedeemedParams {
title: string;
username: string;
input: string;
updateStatus: (newStatus: HelixCustomRewardRedemptionTargetStatus) => Promise<HelixCustomRewardRedemption>;
}
class MusicYTHandler extends MusicHeadHandler {
private readonly youtubeAPIHandler: YoutubeApiHandler;
private maxSongDuration = 60 * 10; // 10min; TODO: add to configs
Expand All @@ -39,24 +50,63 @@ class MusicYTHandler extends MusicHeadHandler {
count: 0
};
private botUserInDB?: UserDocument;
private twitchApi: ApiClient;
private authorizedUser: AuthorizedUserData;
private achievementsHandler: AchievementsHandler;
constructor(
socketIO: Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>,
sayInAuthorizedChannel: (message: string) => void,
configs: MusicConfigs
twitchApi: ApiClient,
authorizedUser: AuthorizedUserData,
configs: MusicConfigs,
achievementsHandler: AchievementsHandler
) {
super(socketIO, sayInAuthorizedChannel, configs, "audioYT");
this.youtubeAPIHandler = new YoutubeApiHandler();

this.twitchApi = twitchApi;
this.authorizedUser = authorizedUser;
this.achievementsHandler = achievementsHandler;
this.init();
}

private async init() {
await this.createIfNotExistSongRequestReward();
this.botUserInDB = await getOneUser({ twitchId: botId }, {});

// TODO: add this.config.initialLoadFromDB ?
await this.loadYoutubeSongsFromDatabase();
}

private async isSongRequestChannelRewardCreated() {
const rewards = await this.twitchApi.channelPoints.getCustomRewards(this.authorizedUser.id);
const foundSongRequestReward = rewards.find((reward) => reward.title === SONG_REQUEST_REWARD_NAME);

return !!foundSongRequestReward;
}

private async createIfNotExistSongRequestReward() {
if (await this.isSongRequestChannelRewardCreated()) return;

this.twitchApi.channelPoints.createCustomReward(this.authorizedUser.id, {
cost: 10,
userInputRequired: true,
title: SONG_REQUEST_REWARD_NAME,
prompt: "Provide name or youtube video id. Links doesn't work here."
});
}

public async handleIfSongRequestRewardIsRedeemed({
title,
input,
username,
updateStatus
}: HandleIfSongRequestRewardIsRedeemedParams) {
if (title !== SONG_REQUEST_REWARD_NAME) return;

const added = await this.requestSong(username, input);
if (!added) await updateStatus("CANCELED");
}

private async loadYoutubeSongsFromDatabase() {
const songsListDB = await getSongs({ enabled: { $ne: false } }, { limit: 100, sort: { lastUsed: 1 } });

Expand Down Expand Up @@ -263,18 +313,46 @@ class MusicYTHandler extends MusicHeadHandler {
}
private async addRequestedSongToPlayer(username: string, song: SongProperties) {
const { id } = song;
if (!this.isAlreadySongInQue(id)) {
const foundUser = await getOneUser({ username }, {});
if (foundUser) {
this.songRequestList.push([{ id: foundUser.id, username: username }, song]);
await this.addSongToQue(song, { id: foundUser.id, username: username });
if (this.isAlreadySongInQue(id)) {
return this.clientSay(`@${username}, your song is already in que, request something else`);
}

await this.addSongToDatabase(song, foundUser._id);
const foundUser = await getOneUser({ username }, {});
if (foundUser) {
const databaseSongData = await this.addSongToDatabase(song, foundUser._id);
if (!databaseSongData) {
this.clientSay(`@${username}, something when wrong adding your song. Try again later :)`);
return false;
}
return true;

return await this.handleSongRequestedSongLogic(foundUser, song, databaseSongData);
}
}

private async handleSongRequestedSongLogic(
user: UserModel,
song: SongProperties,
{ isNew, song: songDB }: CreateSongReturn
) {
if (isNew) {
await this.achievementsHandler.incrementAddNewSongToDatabaseAchievements({
userId: user._id,
username: user.username
});
}

if (!songDB.enabled) {
this.clientSay(
`@${user.username}, song ${songDB.title} is disabled. Request something else. Or provide more precise name :)`
);
return false;
}

this.songRequestList.push([{ id: user._id, username: user.username }, song]);
await this.addSongToQue(song, { id: user._id, username: user.username });
return true;
}

private isAlreadySongInQue(songId: string) {
const isAdded = this.musicQue.some(([id]) => id === songId);

Expand Down Expand Up @@ -306,23 +384,26 @@ class MusicYTHandler extends MusicHeadHandler {
if (added) {
this.emitGetAudioInfo();
this.clientSay(`@${username}, added ${foundSong.name} song to que`);
return;
return true;
}

this.clientSay(`@${username}, your song is already in que, request something else`);
}

private async searchForRequestedSong(searchQuery: string): Promise<SongProperties | undefined> {
const searchedItem = await this.youtubeAPIHandler.getYoutubeSearchVideosIds({
q: searchQuery,
maxResults: 1
});
try {
const searchedItem = await this.youtubeAPIHandler.getYoutubeSearchVideosIds({
q: searchQuery,
maxResults: 1
});

if (!searchedItem) return;
if (!searchedItem) return;

const videoDetails = await this.getYoutubeVideosDetailsById(searchedItem);
const videoDetails = await this.getYoutubeVideosDetailsById(searchedItem);

if (videoDetails) return videoDetails[0];
if (videoDetails) return videoDetails[0];
} catch (err) {
console.error("Error occured in searchForRequestedSong. Probably youtube video doesn't exist");
return;
}
}

private isEnoughRequestSongInfo(username: string, songName: string) {
Expand Down
15 changes: 12 additions & 3 deletions server/src/stream/initializeHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,14 @@ const createHandlers = async ({ configs, twitchApi, authorizedUser, socketIO }:
INITIALIZED_HANDLERS.achievementsHandler = new AchievementsHandler(socketIO, achievementsConfigs);

INITIALIZED_HANDLERS.musicStreamHandler = new MusicStreamHandler(socketIO, sayInAuthorizedChannel, musicConfigs);
INITIALIZED_HANDLERS.musicYTHandler = new MusicYTHandler(socketIO, sayInAuthorizedChannel, musicConfigs);
INITIALIZED_HANDLERS.musicYTHandler = new MusicYTHandler(
socketIO,
sayInAuthorizedChannel,
twitchApi,
authorizedUser,
musicConfigs,
INITIALIZED_HANDLERS.achievementsHandler
);
INITIALIZED_HANDLERS.commandsHandler = new CommandsHandler(
twitchApi,
socketIO,
Expand Down Expand Up @@ -100,7 +107,8 @@ const createHandlers = async ({ configs, twitchApi, authorizedUser, socketIO }:
apiClient: twitchApi,
socketIO,
authorizedUser,
achievementsHandler: INITIALIZED_HANDLERS.achievementsHandler
achievementsHandler: INITIALIZED_HANDLERS.achievementsHandler,
musicYTHandler: INITIALIZED_HANDLERS.musicYTHandler
});

INITIALIZED_HANDLERS.streamHandler = new StreamHandler({
Expand Down Expand Up @@ -131,7 +139,8 @@ const updateHandlers = async ({ configs, twitchApi, authorizedUser, socketIO }:
apiClient: twitchApi,
socketIO,
authorizedUser,
achievementsHandler: INITIALIZED_HANDLERS.achievementsHandler!
achievementsHandler: INITIALIZED_HANDLERS.achievementsHandler!,
musicYTHandler: INITIALIZED_HANDLERS.musicYTHandler!
});

INITIALIZED_HANDLERS.streamHandler?.updateOptions({
Expand Down