package me.moodcat.backend.rooms;
import java.util.Collection;
import java.util.Deque;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import me.moodcat.api.ProfanityChecker;
import me.moodcat.api.models.ChatMessageModel;
import me.moodcat.backend.UnitOfWorkSchedulingService;
import me.moodcat.backend.Vote;
import me.moodcat.database.controllers.SongDAO;
import me.moodcat.database.embeddables.VAVector;
import me.moodcat.database.entities.ChatMessage;
import me.moodcat.database.entities.Room;
import me.moodcat.database.entities.Song;
import me.moodcat.database.entities.User;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.inject.Provider;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
/**
* The instance object of the rooms.
*/
@Slf4j
public class RoomInstance {
/**
* Number of selected songs from the database.
*/
public static final int NUMBER_OF_SELECTED_SONGS = 25;
/**
* Number of chat messages to cache for each room.
*/
public static final int MAXIMAL_NUMBER_OF_CHAT_MESSAGES = 100;
/**
* How much the vector should approach the room vector.
*/
public static final double CLASSIFY_GROW_FACTOR = 0.02;
private static final int MESSAGE_FLOODING_TIMEOUT = 10;
private static final int MESSAGE_FLOODING_MESSAGE_AMOUNT = 4;
/**
* {@link SongInstanceFactory} to create {@link SongInstance SongInstances} with.
*/
private final SongInstanceFactory songInstanceFactory;
/**
* {@link me.moodcat.backend.UnitOfWorkSchedulingServiceImpl} to schedule tasks in a unit of work.
*/
private final UnitOfWorkSchedulingService unitOfWorkSchedulingService;
/**
* The profanity checker to filter out 'bad' chatmessages.
*/
private final ProfanityChecker profanityChecker;
/**
* The room index.
*
* @return the room id
*/
@Getter
private final int id;
/**
* The room name.
*
* @return the room name
*/
@Getter
private final String name;
/**
* The {@link RoomInstanceInUnitOfWorkFactory}.
*/
private final RoomInstanceInUnitOfWorkFactory roomInstanceInUnitOfWorkFactory;
/**
* Keep track of the message index.
*/
private final ChatMessageIdGenerator chatMessageIdGenerator;
private Provider<SongDAO> songDAOProvider;
/**
* The cached messages in order to speed up retrieval.
*/
private final Deque<ChatMessageInstance> messages;
/**
* The current song.
*/
private final AtomicReference<SongInstance> currentSong;
/**
* Has changed flag.
*/
private final AtomicBoolean hasChanged;
/**
* The votes of the users for the current song.
*/
private final Map<User, Vote> votes;
@AssistedInject
public RoomInstance(final SongInstanceFactory songInstanceFactory,
final RoomInstanceInUnitOfWorkFactory roomInstanceInUnitOfWorkFactory,
final UnitOfWorkSchedulingService unitOfWorkSchedulingService,
final ProfanityChecker profanityChecker,
final Provider<SongDAO> songDAOProvider,
@Assisted final Room room) {
Preconditions.checkNotNull(room);
this.profanityChecker = profanityChecker;
this.songInstanceFactory = songInstanceFactory;
this.roomInstanceInUnitOfWorkFactory = roomInstanceInUnitOfWorkFactory;
this.unitOfWorkSchedulingService = unitOfWorkSchedulingService;
this.songDAOProvider = songDAOProvider;
this.votes = Maps.newConcurrentMap();
this.id = room.getId();
this.name = room.getName();
this.chatMessageIdGenerator = new ChatMessageIdGenerator(room);
this.messages = getChatMessageModels(room.getChatMessages());
this.currentSong = new AtomicReference<SongInstance>();
this.hasChanged = new AtomicBoolean(false);
this.scheduleSyncTimer();
this.startPlaying(room.getCurrentSong());
log.info("Initialized room instance {}", this);
}
private static LinkedList<ChatMessageInstance> getChatMessageModels(
final Collection<ChatMessage> messages) {
return Lists.newLinkedList(messages.stream()
.map(ChatMessageInstance::create).collect(Collectors.toList()));
}
protected Future<?> interactWithRoom(final RoomInstanceInUnitOfWorkHandler handler) {
return unitOfWorkSchedulingService.performInUnitOfWork(() -> {
RoomInstanceInUnitOfWork instance = roomInstanceInUnitOfWorkFactory.create(id);
handler.handle(instance);
});
}
/**
* Play a next song. Will fetch from the history if no songs can be found.
*/
public Future<?> playNext() {
return interactWithRoom(instance -> {
processVotes(instance);
Song song = instance.nextSong();
startPlaying(song);
instance.merge();
});
}
/**
* Start playing a song. Should only be called in a {@code UnitOfWork}.
* Used in the constructor to start playing the initial song.
* Then used in the {@link RoomInstance#playNext()} to start playing
* new songs.
*
* @param song
* Song to be played.
*/
@RunInUnitOfWork
protected void startPlaying(final Song song) {
final SongInstance songInstance = songInstanceFactory.create(song);
this.currentSong.set(songInstance);
log.info("Room {} now playing {}", this.id, song);
// Observer: Play the next song when the song is finished
songInstance.addObserver(this::playNext);
}
@RunInUnitOfWork
private void processVotes(final RoomInstanceInUnitOfWork instance) {
int nettoVotes = this.votes.values().stream()
.mapToInt(Vote::getValue)
.sum();
if (nettoVotes < 0) {
instance.excludeRoomFromSong();
} else if (nettoVotes > 0) {
Song previousSong = instance.getCurrentSong();
final VAVector adjusted = adjustSongVectorToRoomVector(instance.getVector(),
previousSong.getValenceArousal());
previousSong.setValenceArousal(adjusted);
songDAOProvider.get().merge(previousSong);
}
this.votes.clear();
}
private VAVector adjustSongVectorToRoomVector(final VAVector roomVector, final VAVector songVector) {
final VAVector adjustment = roomVector.subtract(songVector);
return new VAVector(
Math.min(1, Math.max(-1, songVector.getValence() + adjustment.getValence() * CLASSIFY_GROW_FACTOR)),
Math.min(1, Math.max(-1, songVector.getArousal() + adjustment.getArousal() * CLASSIFY_GROW_FACTOR))
);
}
/**
* Sync room messages to the database.
*/
private void scheduleSyncTimer() {
this.unitOfWorkSchedulingService.scheduleAtFixedRate(this::merge, 1, 1,
TimeUnit.MINUTES);
}
/**
* Store a message in the instance.
*
* @param model
* the message to send.
*/
public ChatMessageModel sendMessage(final ChatMessageModel model,
final User user) {
Preconditions.checkNotNull(model);
verifyNonSpamming(user);
updateAndSetModel(model, user);
ChatMessageInstance chatMessage = new ChatMessageInstance(user.getId(),
model);
messages.addLast(chatMessage);
if (messages.size() > MAXIMAL_NUMBER_OF_CHAT_MESSAGES) {
messages.removeFirst();
}
hasChanged.set(true);
log.info("Sending message {} in room {}", chatMessage, this);
return model;
}
private void verifyNonSpamming(final User user) {
// Our system is allowed to send messages
if (user.getId().equals(1)) {
return;
}
final long currentTime = System.currentTimeMillis();
if (messages
.stream()
.filter((message) -> {
return message.getUserId() == user.getId()
&& message.getTimestamp()
+ TimeUnit.SECONDS.toMillis(MESSAGE_FLOODING_TIMEOUT) > currentTime;
}).count() > MESSAGE_FLOODING_MESSAGE_AMOUNT) {
throw new IllegalArgumentException(String.format(
"You can not post %d messages within %d seconds",
MESSAGE_FLOODING_MESSAGE_AMOUNT, MESSAGE_FLOODING_TIMEOUT));
}
}
private void updateAndSetModel(final ChatMessageModel model, final User user) {
model.setId(chatMessageIdGenerator.generateId());
model.setTimestamp(System.currentTimeMillis());
model.setAuthor(user.getName());
model.setMessage(profanityChecker.clearProfanity(model.getMessage()));
}
/**
* Merge the changes of the instance in the database.
*/
protected Future<?> merge() {
if (hasChanged.getAndSet(false)) {
log.info("Merging changes in room {}", this.getId());
return interactWithRoom(instance -> {
instance.persistMessages(messages);
instance.merge();
});
}
return interactWithRoom(RoomInstanceInUnitOfWork::merge);
}
/**
* The cached messages in order to speed up retrieval.
*
* @return The latest {@link #MAXIMAL_NUMBER_OF_CHAT_MESSAGES} messages.
*/
public List<ChatMessageModel> getMessages() {
return this.messages.stream().map(ChatMessageInstance::transform)
.collect(Collectors.toList());
}
/**
* Get the instance's current song.
*
* @return the current song.
*/
public Song getCurrentSong() {
return this.currentSong.get().getSong();
}
/**
* Get the progress of the current song.
*
* @return the progress in seconds.
*/
public long getCurrentTime() {
return this.currentSong.get().getTime();
}
/**
* Add a vote.
*
* @param user
* User that votes.
* @param valueOf
* Vote value.
*/
public void addVote(final User user, final Vote valueOf) {
if (this.votes.containsKey(user)) {
throw new IllegalArgumentException("User should only vote once!");
}
this.votes.put(user, valueOf);
}
/**
* Interact with a {@link RoomInstanceInUnitOfWork}.
*/
@FunctionalInterface
interface RoomInstanceInUnitOfWorkHandler {
/**
* Interact with the {@link RoomInstanceInUnitOfWork} in a {@code UnitOfWork}.
*
* @param roomInstance
* {@code RoomInstanceInUnitOfWork} to work with.
*/
@RunInUnitOfWork
void handle(RoomInstanceInUnitOfWork roomInstance);
}
}