mirror of
https://github.com/Spengreb/sync.git
synced 2026-05-15 12:02:06 +00:00
Browsers which don't support CyTube's limited subset of generally-supported codecs probably aren't worth warning about. 1Mbps is way too low of a threshold to warn about bandwidth, but even if the threshold for warning were raised, it's probably still not that useful.
1346 lines
39 KiB
JavaScript
1346 lines
39 KiB
JavaScript
var ChannelModule = require("./module"); var ULList = require("../ullist");
|
|
var AsyncQueue = require("../asyncqueue");
|
|
var Media = require("../media");
|
|
var util = require("../utilities");
|
|
var InfoGetter = require("../get-info");
|
|
var Config = require("../config");
|
|
var Flags = require("../flags");
|
|
var db = require("../database");
|
|
var CustomEmbedFilter = require("../customembed").filter;
|
|
var XSS = require("../xss");
|
|
import counters from '../counters';
|
|
|
|
const LOGGER = require('@calzoneman/jsli')('playlist');
|
|
|
|
const MAX_ITEMS = Config.get("playlist.max-items");
|
|
// Limit requestPlaylist to once per 60 seconds
|
|
const REQ_PLAYLIST_THROTTLE = {
|
|
burst: 1,
|
|
sustained: 0,
|
|
cooldown: 60
|
|
};
|
|
|
|
|
|
const TYPE_QUEUE = {
|
|
id: "string,boolean",
|
|
type: "string",
|
|
pos: "string",
|
|
title: "string,boolean,optional",
|
|
duration: "number,optional",
|
|
temp: "boolean,optional"
|
|
};
|
|
|
|
const TYPE_SET_TEMP = {
|
|
uid: "number",
|
|
temp: "boolean"
|
|
};
|
|
|
|
const TYPE_MOVE_MEDIA = {
|
|
from: "number",
|
|
after: "string,number"
|
|
};
|
|
|
|
const TYPE_ASSIGN_LEADER = {
|
|
name: "string"
|
|
};
|
|
|
|
const TYPE_MEDIA_UPDATE = {
|
|
id: "string",
|
|
currentTime: "number",
|
|
paused: "boolean,optional",
|
|
type: "string,optional"
|
|
};
|
|
|
|
const TYPE_CLONE_PLAYLIST = {
|
|
name: "string"
|
|
};
|
|
|
|
const TYPE_QUEUE_PLAYLIST = {
|
|
name: "string",
|
|
pos: "string",
|
|
temp: "boolean,optional"
|
|
};
|
|
|
|
function PlaylistItem(media, opts) {
|
|
if (typeof opts !== "object") {
|
|
opts = {};
|
|
}
|
|
this.media = media;
|
|
this.uid = opts.uid;
|
|
this.temp = Boolean(opts.temp);
|
|
this.queueby = (typeof opts.queueby === "string") ? opts.queueby : "";
|
|
this.next = null;
|
|
this.prev = null;
|
|
}
|
|
|
|
PlaylistItem.prototype = {
|
|
pack: function () {
|
|
return {
|
|
media: this.media.pack(),
|
|
uid: this.uid,
|
|
temp: this.temp,
|
|
queueby: this.queueby
|
|
};
|
|
}
|
|
};
|
|
|
|
function PlaylistModule(channel) {
|
|
ChannelModule.apply(this, arguments);
|
|
this.items = new ULList();
|
|
this.meta = {
|
|
count: 0,
|
|
rawTime: 0,
|
|
time: util.formatTime(0)
|
|
};
|
|
this.current = null;
|
|
this._nextuid = 0;
|
|
this.semaphore = new AsyncQueue();
|
|
|
|
this.leader = null;
|
|
this._leadInterval = false;
|
|
this._lastUpdate = 0;
|
|
this._counter = 0;
|
|
this._refreshing = false;
|
|
|
|
if (this.channel.modules.chat) {
|
|
this.channel.modules.chat.registerCommand("/clean", this.handleClean.bind(this));
|
|
this.channel.modules.chat.registerCommand("/cleantitle", this.handleClean.bind(this));
|
|
}
|
|
}
|
|
|
|
PlaylistModule.prototype = Object.create(ChannelModule.prototype);
|
|
|
|
PlaylistModule.prototype.load = function (data) {
|
|
var self = this;
|
|
var playlist = data.playlist;
|
|
if (typeof playlist !== "object" || !("pl" in playlist)) {
|
|
return;
|
|
}
|
|
|
|
var i = 0;
|
|
playlist.pos = parseInt(playlist.pos);
|
|
playlist.pl.forEach(function (item) {
|
|
if (item.media.type === "cu" && item.media.id.indexOf("cu:") !== 0) {
|
|
try {
|
|
item.media = CustomEmbedFilter(item.media.id);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
} else if (item.media.type === "gd") {
|
|
delete item.media.meta.gpdirect;
|
|
}
|
|
|
|
var m = new Media(item.media.id, item.media.title, item.media.seconds,
|
|
item.media.type, item.media.meta || {});
|
|
var newitem = new PlaylistItem(m, {
|
|
uid: self._nextuid++,
|
|
temp: item.temp,
|
|
queueby: item.queueby
|
|
});
|
|
|
|
self.items.append(newitem);
|
|
self.meta.count++;
|
|
self.meta.rawTime += m.seconds;
|
|
if (playlist.pos === i) {
|
|
self.current = newitem;
|
|
}
|
|
i++;
|
|
});
|
|
|
|
self.meta.time = util.formatTime(self.meta.rawTime);
|
|
self.startPlayback(playlist.time);
|
|
};
|
|
|
|
PlaylistModule.prototype.save = function (data) {
|
|
var arr = this.items.toArray();
|
|
var pos = 0;
|
|
for (var i = 0; i < arr.length; i++) {
|
|
if (this.current && arr[i].uid == this.current.uid) {
|
|
pos = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
var time = 0;
|
|
if (this.current) {
|
|
time = this.current.media.currentTime;
|
|
}
|
|
|
|
data.playlist = {
|
|
pl: arr,
|
|
pos: pos,
|
|
time: time
|
|
};
|
|
};
|
|
|
|
PlaylistModule.prototype.unload = function () {
|
|
if (this._leadInterval) {
|
|
clearInterval(this._leadInterval);
|
|
this._leadInterval = false;
|
|
}
|
|
|
|
this.channel = null;
|
|
};
|
|
|
|
PlaylistModule.prototype.packInfo = function (data, isAdmin) {
|
|
if (this.current) {
|
|
data.mediatitle = this.current.media.title;
|
|
if (isAdmin) {
|
|
data.mediaLink = util.formatLink(this.current.media.id, this.current.media.type);
|
|
}
|
|
} else {
|
|
data.mediatitle = "(Nothing Playing)";
|
|
if (isAdmin) {
|
|
data.mediaLink = "#";
|
|
}
|
|
}
|
|
|
|
if (isAdmin) {
|
|
if (this.leader) {
|
|
data.leader = this.leader.getName();
|
|
} else {
|
|
data.leader = "[server]";
|
|
}
|
|
}
|
|
};
|
|
|
|
PlaylistModule.prototype.onUserPostJoin = function (user) {
|
|
this.sendPlaylist([user]);
|
|
this.sendChangeMedia([user]);
|
|
user.socket.typecheckedOn("queue", TYPE_QUEUE, this.handleQueue.bind(this, user));
|
|
user.socket.typecheckedOn("setTemp", TYPE_SET_TEMP, this.handleSetTemp.bind(this, user));
|
|
user.socket.typecheckedOn("moveMedia", TYPE_MOVE_MEDIA, this.handleMoveMedia.bind(this, user));
|
|
user.socket.on("delete", this.handleDelete.bind(this, user));
|
|
user.socket.on("jumpTo", this.handleJumpTo.bind(this, user));
|
|
user.socket.on("playNext", this.handlePlayNext.bind(this, user));
|
|
user.socket.typecheckedOn("assignLeader", TYPE_ASSIGN_LEADER, this.handleAssignLeader.bind(this, user));
|
|
user.socket.typecheckedOn("mediaUpdate", TYPE_MEDIA_UPDATE, this.handleUpdate.bind(this, user));
|
|
var self = this;
|
|
user.socket.on("playerReady", function () {
|
|
self.sendChangeMedia([user]);
|
|
});
|
|
user.socket.on("requestPlaylist", this.handleRequestPlaylist.bind(this, user));
|
|
user.waitFlag(Flags.U_HAS_CHANNEL_RANK, function () {
|
|
self.sendPlaylist([user]);
|
|
});
|
|
user.socket.on("clearPlaylist", this.handleClear.bind(this, user));
|
|
user.socket.on("shufflePlaylist", this.handleShuffle.bind(this, user));
|
|
/* User playlists */
|
|
user.socket.on("listPlaylists", this.handleListPlaylists.bind(this, user));
|
|
if (user.is(Flags.U_REGISTERED)) {
|
|
this.handleListPlaylists(user);
|
|
}
|
|
user.socket.typecheckedOn("clonePlaylist", TYPE_CLONE_PLAYLIST, this.handleClonePlaylist.bind(this, user));
|
|
user.socket.typecheckedOn("deletePlaylist", TYPE_CLONE_PLAYLIST, this.handleDeletePlaylist.bind(this, user));
|
|
user.socket.typecheckedOn("queuePlaylist", TYPE_QUEUE_PLAYLIST, this.handleQueuePlaylist.bind(this, user));
|
|
};
|
|
|
|
PlaylistModule.prototype.resumeAutolead = function () {
|
|
this.channel.broadcastAll("setLeader", "");
|
|
|
|
this.channel.logger.log("[playlist] Resuming autolead");
|
|
if (this.current !== null) {
|
|
// Ensure the video is unpaused before resuming autolead.
|
|
// In the past, people have reported stuck playlists because
|
|
// they assigned leader, paused, then removed leader.
|
|
this.current.media.paused = false;
|
|
this.sendMediaUpdate(this.channel.users);
|
|
|
|
if (!this._leadInterval && this.current.media.seconds > 0) {
|
|
this._lastUpdate = Date.now();
|
|
this._leadInterval = setInterval(this._leadLoop.bind(this), 1000);
|
|
this._leadLoop();
|
|
}
|
|
}
|
|
};
|
|
|
|
PlaylistModule.prototype.onUserPart = function (user) {
|
|
if (this.leader === user) {
|
|
this.leader = null;
|
|
this.resumeAutolead();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* == Functions for sending various playlist data to users ==
|
|
*/
|
|
|
|
PlaylistModule.prototype.sendPlaylist = function (users) {
|
|
var pl = this.items.toArray(true);
|
|
var perms = this.channel.modules.permissions;
|
|
var self = this;
|
|
users.forEach(function (u) {
|
|
u.socket.emit("setPlaylistMeta", self.meta);
|
|
if (!perms.canSeePlaylist(u)) {
|
|
return;
|
|
}
|
|
u.socket.emit("playlist", pl);
|
|
if (self.current) {
|
|
u.socket.emit("setCurrent", self.current.uid);
|
|
}
|
|
});
|
|
};
|
|
|
|
PlaylistModule.prototype.sendChangeMedia = function (users) {
|
|
if (!this.current || !this.current.media || this._refreshing) {
|
|
return;
|
|
}
|
|
|
|
var update = this.current.media.getFullUpdate();
|
|
var uid = this.current.uid;
|
|
if (users === this.channel.users) {
|
|
this.channel.broadcastAll("setCurrent", uid);
|
|
this.channel.broadcastAll("changeMedia", update);
|
|
|
|
var m = this.current.media;
|
|
this.channel.logger.log("[playlist] Now playing: " + m.title +
|
|
" (" + m.type + ":" + m.id + ")");
|
|
} else {
|
|
users.forEach(function (u) {
|
|
u.socket.emit("setCurrent", uid);
|
|
u.socket.emit("changeMedia", update);
|
|
});
|
|
}
|
|
};
|
|
|
|
PlaylistModule.prototype.sendMediaUpdate = function (users) {
|
|
if (!this.current || !this.current.media) {
|
|
return;
|
|
}
|
|
|
|
var update = this.current.media.getTimeUpdate();
|
|
if (users === this.channel.users) {
|
|
this.channel.broadcastAll("mediaUpdate", update);
|
|
} else {
|
|
users.forEach(function (u) {
|
|
u.socket.emit("mediaUpdate", update);
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* == Handlers for playlist manipulation ==
|
|
*/
|
|
|
|
PlaylistModule.prototype.handleQueue = function (user, data) {
|
|
if (typeof data.id === "boolean" && data.id !== false) {
|
|
return;
|
|
}
|
|
|
|
var id = data.id;
|
|
var type = data.type;
|
|
if (type === "lib") {
|
|
LOGGER.warn("Outdated client: IP %s emitting queue with type=lib",
|
|
user.realip);
|
|
}
|
|
|
|
if (data.pos !== "next" && data.pos !== "end") {
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Specifying a custom title is currently only allowed for custom media
|
|
* and raw files
|
|
*/
|
|
if (typeof data.title !== "string" || (data.type !== "cu" && data.type !== "fi")) {
|
|
data.title = false;
|
|
}
|
|
|
|
var link = util.formatLink(id, type);
|
|
var perms = this.channel.modules.permissions;
|
|
|
|
if (!perms.canAddVideo(user, data)) {
|
|
return;
|
|
}
|
|
|
|
if (data.pos === "next" && !perms.canAddNext(user)) {
|
|
return;
|
|
}
|
|
|
|
/* Certain media types require special permission to add */
|
|
if (data.type === "yp" && !perms.canAddList(user)) {
|
|
user.socket.emit("queueFail", {
|
|
msg: "You don't have permission to add playlists",
|
|
link: link,
|
|
id: id
|
|
});
|
|
return;
|
|
} else if (util.isLive(type) && !perms.canAddLive(user)) {
|
|
user.socket.emit("queueFail", {
|
|
msg: "You don't have permission to add live media",
|
|
link: link,
|
|
id: id
|
|
});
|
|
return;
|
|
} else if (type === "cu" && !perms.canAddCustom(user)) {
|
|
user.socket.emit("queueFail", {
|
|
msg: "You don't have permission to add custom embeds",
|
|
link: link,
|
|
id: id
|
|
});
|
|
return;
|
|
} else if (type === "fi" && !perms.canAddRawFile(user)) {
|
|
user.socket.emit("queueFail", {
|
|
msg: "You don't have permission to add raw video files",
|
|
link: link,
|
|
id: id
|
|
});
|
|
return;
|
|
}
|
|
|
|
var temp = data.temp || !perms.canAddNonTemp(user);
|
|
var queueby = user.getName();
|
|
|
|
var duration = undefined;
|
|
if (util.isLive(type) && typeof data.duration === "number") {
|
|
duration = !isNaN(data.duration) ? data.duration : undefined;
|
|
}
|
|
|
|
var limit = {
|
|
burst: 3,
|
|
sustained: 1
|
|
};
|
|
|
|
if (user.account.effectiveRank >= 2) {
|
|
limit = {
|
|
burst: 10,
|
|
sustained: 2
|
|
};
|
|
}
|
|
|
|
if (user.queueLimiter.throttle(limit)) {
|
|
user.socket.emit("queueFail", {
|
|
msg: "You are adding videos too quickly",
|
|
link: link,
|
|
id: id
|
|
});
|
|
return;
|
|
}
|
|
|
|
var maxlength = 0;
|
|
if (!perms.canExceedMaxLength(user)) {
|
|
if (this.channel.modules.options) {
|
|
maxlength = this.channel.modules.options.get("maxlength");
|
|
}
|
|
}
|
|
|
|
data = {
|
|
id: data.id,
|
|
type: data.type,
|
|
pos: data.pos,
|
|
title: data.title,
|
|
link: link,
|
|
temp: temp,
|
|
shouldAddToLibrary: !temp,
|
|
queueby: queueby,
|
|
duration: duration,
|
|
maxlength: maxlength
|
|
};
|
|
|
|
if (data.type === "yp") {
|
|
this.queueYouTubePlaylist(user, data);
|
|
} else {
|
|
this.queueStandard(user, data);
|
|
}
|
|
};
|
|
|
|
PlaylistModule.prototype.queueStandard = function (user, data) {
|
|
var error = function (what) {
|
|
user.socket.emit("queueFail", {
|
|
msg: what,
|
|
link: data.link,
|
|
id: data.id
|
|
});
|
|
};
|
|
|
|
const self = this;
|
|
this.channel.refCounter.ref("PlaylistModule::queueStandard");
|
|
counters.add("playlist:queue:count", 1);
|
|
this.semaphore.queue(function (lock) {
|
|
var lib = self.channel.modules.library;
|
|
if (lib && self.channel.is(Flags.C_REGISTERED) && !util.isLive(data.type)) {
|
|
// TODO: remove this check entirely once metrics are collected.
|
|
lib.getItem(data.id, function (err, item) {
|
|
if (err && err !== "Item not in library") {
|
|
LOGGER.error("Failed to query for library item: %s", String(err));
|
|
} else if (err === "Item not in library") {
|
|
counters.add("playlist:queue:library:miss", 1);
|
|
} else {
|
|
// temp hack until all clients are updated.
|
|
// previously, library search results would queue with
|
|
// type "lib"; this has now been changed.
|
|
data.type = item.type;
|
|
counters.add("playlist:queue:library:hit", 1);
|
|
}
|
|
|
|
handleLookup();
|
|
});
|
|
} else {
|
|
handleLookup();
|
|
}
|
|
|
|
function handleLookup() {
|
|
var channelName = self.channel.name;
|
|
InfoGetter.getMedia(data.id, data.type, function (err, media) {
|
|
if (err) {
|
|
error(XSS.sanitizeText(String(err)));
|
|
self.channel.refCounter.unref("PlaylistModule::queueStandard");
|
|
return lock.release();
|
|
}
|
|
|
|
self._addItem(media, data, user, function () {
|
|
lock.release();
|
|
self.channel.refCounter.unref("PlaylistModule::queueStandard");
|
|
});
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
PlaylistModule.prototype.queueYouTubePlaylist = function (user, data) {
|
|
var error = function (what) {
|
|
user.socket.emit("queueFail", {
|
|
msg: what,
|
|
link: data.link,
|
|
id: data.id
|
|
});
|
|
};
|
|
|
|
var self = this;
|
|
this.semaphore.queue(function (lock) {
|
|
InfoGetter.getMedia(data.id, data.type, function (err, vids) {
|
|
if (err) {
|
|
error(err+"");
|
|
return lock.release();
|
|
}
|
|
|
|
if (self.dead) {
|
|
return lock.release();
|
|
}
|
|
|
|
/**
|
|
* Add videos in reverse order if queueing a playlist next.
|
|
* This is because each video gets added after the currently playing video
|
|
*/
|
|
if (data.pos === "next") {
|
|
vids = vids.reverse();
|
|
/* Special case: when the playlist is empty, add the real first video */
|
|
if (self.items.length === 0) {
|
|
vids.unshift(vids.pop());
|
|
}
|
|
}
|
|
|
|
self.channel.refCounter.ref("PlaylistModule::queueYouTubePlaylist");
|
|
vids.forEach(function (media) {
|
|
data.link = util.formatLink(media.id, media.type);
|
|
self._addItem(media, data, user);
|
|
});
|
|
self.channel.refCounter.unref("PlaylistModule::queueYouTubePlaylist");
|
|
|
|
lock.release();
|
|
});
|
|
});
|
|
};
|
|
|
|
PlaylistModule.prototype.handleDelete = function (user, data) {
|
|
var self = this;
|
|
var perms = this.channel.modules.permissions;
|
|
if (!perms.canDeleteVideo(user)) {
|
|
return;
|
|
}
|
|
|
|
if (typeof data !== "number") {
|
|
return;
|
|
}
|
|
|
|
var plitem = this.items.find(data);
|
|
self.channel.refCounter.ref("PlaylistModule::handleDelete");
|
|
this.semaphore.queue(function (lock) {
|
|
if (self._delete(data)) {
|
|
self.channel.logger.log("[playlist] " + user.getName() + " deleted " +
|
|
plitem.media.title);
|
|
}
|
|
|
|
lock.release();
|
|
self.channel.refCounter.unref("PlaylistModule::handleDelete");
|
|
});
|
|
};
|
|
|
|
PlaylistModule.prototype.handleSetTemp = function (user, data) {
|
|
if (!this.channel.modules.permissions.canSetTemp(user)) {
|
|
return;
|
|
}
|
|
|
|
var item = this.items.find(data.uid);
|
|
if (!item) {
|
|
return;
|
|
}
|
|
|
|
item.temp = data.temp;
|
|
this.channel.broadcastAll("setTemp", data);
|
|
|
|
if (!data.temp && this.channel.modules.library) {
|
|
this.channel.modules.library.cacheMedia(item.media);
|
|
}
|
|
};
|
|
|
|
PlaylistModule.prototype.handleMoveMedia = function (user, data) {
|
|
if (!this.channel.modules.permissions.canMoveVideo(user)) {
|
|
return;
|
|
}
|
|
|
|
var from = this.items.find(data.from);
|
|
var after = this.items.find(data.after);
|
|
|
|
if (!from || from === after) {
|
|
return;
|
|
}
|
|
|
|
const self = this;
|
|
self.channel.refCounter.ref("PlaylistModule::handleMoveMedia");
|
|
self.semaphore.queue(function (lock) {
|
|
if (!self.items.remove(data.from)) {
|
|
self.channel.refCounter.unref("PlaylistModule::handleMoveMedia");
|
|
return lock.release();
|
|
}
|
|
|
|
if (data.after === "prepend") {
|
|
if (!self.items.prepend(from)) {
|
|
self.channel.refCounter.unref("PlaylistModule::handleMoveMedia");
|
|
return lock.release();
|
|
}
|
|
} else if (data.after === "append") {
|
|
if (!self.items.append(from)) {
|
|
self.channel.refCounter.unref("PlaylistModule::handleMoveMedia");
|
|
return lock.release();
|
|
}
|
|
} else {
|
|
if (!self.items.insertAfter(from, data.after)) {
|
|
self.channel.refCounter.unref("PlaylistModule::handleMoveMedia");
|
|
return lock.release();
|
|
}
|
|
}
|
|
|
|
self.channel.broadcastAll("moveVideo", data);
|
|
|
|
self.channel.logger.log("[playlist] " + user.getName() + " moved " +
|
|
from.media.title +
|
|
(after ? " after " + after.media.title : ""));
|
|
lock.release();
|
|
self.channel.refCounter.unref("PlaylistModule::handleMoveMedia");
|
|
});
|
|
};
|
|
|
|
PlaylistModule.prototype.handleJumpTo = function (user, data) {
|
|
if (typeof data !== "string" && typeof data !== "number") {
|
|
return;
|
|
}
|
|
|
|
if (!this.channel.modules.permissions.canSkipVideo(user)) {
|
|
return;
|
|
}
|
|
|
|
var to = this.items.find(data);
|
|
var title = "";
|
|
if (this.current) {
|
|
title = " from " + this.current.media.title;
|
|
}
|
|
|
|
if (to) {
|
|
title += " to " + to.media.title;
|
|
var old = this.current;
|
|
this.current = to;
|
|
this.startPlayback();
|
|
this.channel.logger.log("[playlist] " + user.getName() + " skipped " + title);
|
|
|
|
if (old && old.temp && old !== to) {
|
|
this._delete(old.uid);
|
|
}
|
|
}
|
|
};
|
|
|
|
PlaylistModule.prototype.handlePlayNext = function (user) {
|
|
if (!this.channel.modules.permissions.canSkipVideo(user)) {
|
|
return;
|
|
}
|
|
|
|
var title = "";
|
|
if (this.current) {
|
|
title = this.current.media.title;
|
|
}
|
|
|
|
this.channel.logger.log("[playlist] " + user.getName() + " skipped " + title);
|
|
this._playNext();
|
|
};
|
|
|
|
PlaylistModule.prototype.handleClear = function (user) {
|
|
if (!this.channel.modules.permissions.canClearPlaylist(user)) {
|
|
return;
|
|
}
|
|
|
|
this.channel.logger.log("[playlist] " + user.getName() + " cleared the playlist");
|
|
this.current = null;
|
|
this.items.clear();
|
|
this.semaphore.reset();
|
|
|
|
this.meta = {
|
|
count: 0,
|
|
rawTime: 0,
|
|
time: util.formatTime(0)
|
|
};
|
|
|
|
this.channel.broadcastAll("playlist", []);
|
|
this.channel.broadcastAll("setPlaylistMeta", this.meta);
|
|
};
|
|
|
|
PlaylistModule.prototype.handleShuffle = function (user) {
|
|
if (!this.channel.modules.permissions.canShufflePlaylist(user)) {
|
|
return;
|
|
}
|
|
|
|
this.channel.logger.log("[playlist] " + user.getName() + " shuffled the playlist");
|
|
|
|
var pl = this.items.toArray(false);
|
|
this.items.clear();
|
|
this.semaphore.reset();
|
|
while (pl.length > 0) {
|
|
var i = Math.floor(Math.random() * pl.length);
|
|
var item = new PlaylistItem(pl[i].media, {
|
|
uid: this._nextuid++,
|
|
temp: pl[i].temp,
|
|
queueby: pl[i].queueby
|
|
});
|
|
|
|
this.items.append(item);
|
|
pl.splice(i, 1);
|
|
}
|
|
|
|
this.current = this.items.first;
|
|
pl = this.items.toArray(true);
|
|
var perms = this.channel.modules.permissions;
|
|
this.channel.users.forEach(function (u) {
|
|
if (perms.canSeePlaylist(u)) {
|
|
u.socket.emit("playlist", pl);
|
|
};
|
|
});
|
|
this.startPlayback();
|
|
};
|
|
|
|
/**
|
|
* == Leader stuff ==
|
|
*/
|
|
PlaylistModule.prototype.handleAssignLeader = function (user, data) {
|
|
if (!this.channel.modules.permissions.canAssignLeader(user)) {
|
|
return user.kick("Attempted assignLeader without sufficient permission");
|
|
}
|
|
|
|
var name = data.name;
|
|
|
|
if (this.leader) {
|
|
var old = this.leader;
|
|
this.leader = null;
|
|
if (old.account.effectiveRank === 1.5) {
|
|
old.account.effectiveRank = old.account.oldRank;
|
|
old.emit("effectiveRankChange", old.account.effectiveRank, 1.5);
|
|
old.socket.emit("rank", old.account.effectiveRank);
|
|
}
|
|
|
|
this.channel.broadcastAll("setUserRank", {
|
|
name: old.getName(),
|
|
rank: old.account.effectiveRank
|
|
});
|
|
this.channel.logger.log("[mod] " + user.getName() + " removed leader from " + old.getName());
|
|
}
|
|
|
|
if (!name) {
|
|
this.resumeAutolead();
|
|
return;
|
|
}
|
|
|
|
for (var i = 0; i < this.channel.users.length; i++) {
|
|
if (this.channel.users[i].getName() === name) {
|
|
this.channel.logger.log("[playlist] Assigned leader: " + name);
|
|
this.leader = this.channel.users[i];
|
|
if (this._leadInterval) {
|
|
clearInterval(this._leadInterval);
|
|
this._leadInterval = false;
|
|
}
|
|
if (this.leader.account.effectiveRank < 1.5) {
|
|
this.leader.account.oldRank = this.leader.account.effectiveRank;
|
|
this.leader.account.effectiveRank = 1.5;
|
|
this.leader.emit("effectiveRankChange", 1.5, this.leader.account.oldRank);
|
|
this.leader.socket.emit("rank", 1.5);
|
|
}
|
|
|
|
this.channel.broadcastAll("setLeader", name);
|
|
if (this.leader.account.effectiveRank === 1.5) {
|
|
this.channel.broadcastAll("setUserRank", {
|
|
name: name,
|
|
rank: 1.5
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (this.leader === null) {
|
|
user.socket.emit("errorMsg", {
|
|
msg: "Unable to assign leader: could not find user " + name
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.channel.logger.log("[mod] " + user.getName() + " assigned leader to " + data.name);
|
|
};
|
|
|
|
PlaylistModule.prototype.handleUpdate = function (user, data) {
|
|
if (this.leader !== user) {
|
|
return;
|
|
}
|
|
|
|
if (!this.current) {
|
|
return;
|
|
}
|
|
|
|
var media = this.current.media;
|
|
if (util.isLive(media.type)) {
|
|
return;
|
|
}
|
|
|
|
if (media.id !== data.id || isNaN(data.currentTime)) {
|
|
return;
|
|
}
|
|
|
|
media.currentTime = data.currentTime;
|
|
media.paused = Boolean(data.paused);
|
|
var update = media.getTimeUpdate();
|
|
|
|
this.channel.broadcastAll("mediaUpdate", update);
|
|
};
|
|
|
|
/**
|
|
* == Internal playlist manipulation ==
|
|
*/
|
|
|
|
PlaylistModule.prototype._delete = function (uid) {
|
|
var self = this;
|
|
var perms = this.channel.modules.permissions;
|
|
|
|
var item = self.items.find(uid);
|
|
if (!item) {
|
|
return false;
|
|
}
|
|
var next = item.next || this.items.first;
|
|
|
|
var success = self.items.remove(uid);
|
|
|
|
if (success) {
|
|
self.meta.count--;
|
|
self.meta.rawTime -= item.media.seconds;
|
|
self.meta.time = util.formatTime(self.meta.rawTime);
|
|
self.channel.users.forEach(function (u) {
|
|
if (perms.canSeePlaylist(u)) {
|
|
u.socket.emit("delete", { uid: uid });
|
|
}
|
|
u.socket.emit("setPlaylistMeta", self.meta);
|
|
});
|
|
}
|
|
|
|
if (self.current === item && item === next) {
|
|
self.current = null;
|
|
} else if (self.current === item) {
|
|
self.current = next;
|
|
self.startPlayback();
|
|
}
|
|
|
|
return success;
|
|
};
|
|
|
|
PlaylistModule.prototype._addItem = function (media, data, user, cb) {
|
|
var self = this;
|
|
var allowDuplicates = false;
|
|
if (this.channel.modules.options && this.channel.modules.options.get("allow_dupes")) {
|
|
allowDuplicates = true;
|
|
}
|
|
|
|
var qfail = function (msg) {
|
|
user.socket.emit("queueFail", {
|
|
msg: msg,
|
|
link: data.link,
|
|
id: data.id
|
|
});
|
|
if (cb) {
|
|
cb();
|
|
}
|
|
};
|
|
|
|
if (data.duration) {
|
|
media.seconds = data.duration;
|
|
media.duration = util.formatTime(media.seconds);
|
|
}
|
|
|
|
if (data.maxlength > 0 && media.seconds > data.maxlength) {
|
|
return qfail("Video exceeds the maximum length set by the channel admin: " +
|
|
data.maxlength + " seconds");
|
|
}
|
|
|
|
if (this.items.length >= MAX_ITEMS) {
|
|
return qfail("Playlist limit reached (" + MAX_ITEMS + ")");
|
|
}
|
|
|
|
var existing = this.items.findVideoId(media.id);
|
|
if (existing && !allowDuplicates && (data.pos === "end" || existing === this.current)) {
|
|
return qfail("This item is already on the playlist");
|
|
}
|
|
|
|
var usersItems = this.items.findAll(function (item) {
|
|
return item.queueby.toLowerCase() === user.getLowerName();
|
|
});
|
|
|
|
if (this.channel.modules.options &&
|
|
this.channel.modules.options.get("playlist_max_per_user") &&
|
|
usersItems.length >= this.channel.modules.options.get("playlist_max_per_user")) {
|
|
|
|
if (!this.channel.modules.permissions.canExceedMaxItemsPerUser(user)) {
|
|
return qfail("Channel limit exceeded: maximum number of videos per user");
|
|
}
|
|
}
|
|
|
|
if (this.channel.modules.options &&
|
|
this.channel.modules.options.get("playlist_max_duration_per_user") > 0) {
|
|
|
|
const limit = this.channel.modules.options.get("playlist_max_duration_per_user");
|
|
const totalDuration = usersItems.map(item => item.media.seconds).reduce((a, b) => a + b, 0) + media.seconds;
|
|
if (isNaN(totalDuration)) {
|
|
LOGGER.error("playlist_max_duration_per_user check calculated NaN: " + require('util').inspect(usersItems));
|
|
} else if (totalDuration >= limit && !this.channel.modules.permissions.canExceedMaxDurationPerUser(user)) {
|
|
return qfail("Channel limit exceeded: maximum total playlist time per user");
|
|
}
|
|
}
|
|
|
|
/* Warn about blocked countries */
|
|
if (media.meta.restricted) {
|
|
user.socket.emit("queueWarn", {
|
|
msg: "Video is blocked in the following countries: " + media.meta.restricted,
|
|
link: data.link
|
|
});
|
|
}
|
|
|
|
var item = new PlaylistItem(media, {
|
|
uid: self._nextuid++,
|
|
temp: data.temp,
|
|
queueby: data.queueby
|
|
});
|
|
|
|
if (data.title && (media.type === "cu" || media.type === "fi")) {
|
|
media.setTitle(data.title);
|
|
}
|
|
|
|
var success = function () {
|
|
var packet = {
|
|
item: item.pack(),
|
|
after: item.prev ? item.prev.uid : "prepend"
|
|
};
|
|
|
|
self.meta.count++;
|
|
self.meta.rawTime += media.seconds;
|
|
self.meta.time = util.formatTime(self.meta.rawTime);
|
|
var m = item.media;
|
|
self.channel.logger.log("[playlist] " + (data.queueby || "(anonymous)") +
|
|
" added " + m.title + " (" + m.type + ":" + m.id + ")");
|
|
|
|
var perms = self.channel.modules.permissions;
|
|
self.channel.users.forEach(function (u) {
|
|
if (perms.canSeePlaylist(u)) {
|
|
u.socket.emit("queue", packet);
|
|
}
|
|
|
|
u.socket.emit("setPlaylistMeta", self.meta);
|
|
});
|
|
|
|
if (data.shouldAddToLibrary && !util.isLive(media.type)) {
|
|
if (self.channel.modules.library) {
|
|
self.channel.modules.library.cacheMedia(media);
|
|
}
|
|
}
|
|
|
|
if (self.items.length === 1) {
|
|
self.current = item;
|
|
self.startPlayback();
|
|
}
|
|
|
|
if (cb) {
|
|
cb();
|
|
}
|
|
};
|
|
|
|
if (data.pos === "end" || this.current == null) {
|
|
this.items.append(item);
|
|
return success();
|
|
} else {
|
|
if (this.items.insertAfter(item, this.current.uid)) {
|
|
if (existing && !allowDuplicates) {
|
|
item.temp = existing.temp;
|
|
this._delete(existing.uid);
|
|
}
|
|
return success();
|
|
} else {
|
|
return qfail("Playlist failure");
|
|
}
|
|
}
|
|
};
|
|
|
|
function isExpired(media) {
|
|
if (media.meta.expiration && media.meta.expiration < Date.now()) {
|
|
return true;
|
|
} else if (media.type === "gd") {
|
|
return !media.meta.object;
|
|
} else if (media.type === "vi") {
|
|
return !media.meta.direct;
|
|
}
|
|
}
|
|
|
|
PlaylistModule.prototype.startPlayback = function (time) {
|
|
var self = this;
|
|
|
|
if (!self.current || !self.current.media) {
|
|
return false;
|
|
}
|
|
|
|
var media = self.current.media;
|
|
media.reset();
|
|
|
|
if (self.leader != null) {
|
|
media.paused = false;
|
|
media.currentTime = time || 0;
|
|
self.channel.checkModules("onPreMediaChange", [self.current.media],
|
|
function () {
|
|
/*
|
|
* onPreMediaChange doesn't care about the callback result.
|
|
* Its purpose is to allow modification of playback data before
|
|
* users are sent a changeMedia
|
|
*/
|
|
if (!self.current || !self.current.media) {
|
|
return;
|
|
}
|
|
|
|
self.sendChangeMedia(self.channel.users);
|
|
self.channel.notifyModules("onMediaChange", [self.current.media]);
|
|
}
|
|
);
|
|
return;
|
|
}
|
|
|
|
/* Lead-in time of 3 seconds to allow clients to buffer */
|
|
time = time || (media.seconds > 0 ? -3 : 0);
|
|
media.paused = time < 0;
|
|
media.currentTime = time;
|
|
|
|
/* Module was already leading, stop the previous timer */
|
|
if (self._leadInterval) {
|
|
clearInterval(self._leadInterval);
|
|
self._leadInterval = false;
|
|
}
|
|
|
|
self.channel.checkModules("onPreMediaChange", [self.current.media],
|
|
function () {
|
|
/*
|
|
* onPreMediaChange currently doesn't care about the callback result.
|
|
* Its purpose is to allow modification of playback data before
|
|
* users are sent a changeMedia
|
|
*/
|
|
if (!self.current || !self.current.media) {
|
|
return;
|
|
}
|
|
|
|
self.sendChangeMedia(self.channel.users);
|
|
self.channel.notifyModules("onMediaChange", [self.current.media]);
|
|
|
|
/* Only start the timer if the media item is not live, i.e. has a duration */
|
|
/*
|
|
* 2015-01-22: Don't start the timer if there is an active leader or if
|
|
* the timer is already running. Both are possible since checkModules()
|
|
* is asynchronous
|
|
*/
|
|
if (media.seconds > 0 && !self.leader && !self._leadInterval) {
|
|
self._lastUpdate = Date.now();
|
|
self._leadInterval = setInterval(function() {
|
|
self._leadLoop();
|
|
}, 1000);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const UPDATE_INTERVAL = Config.get("playlist.update-interval");
|
|
|
|
PlaylistModule.prototype._leadLoop = function() {
|
|
if (this.current == null) {
|
|
return;
|
|
}
|
|
|
|
if (!this.channel || this.channel.dead) {
|
|
if (this._leadInterval) {
|
|
clearInterval(this._leadInterval);
|
|
this._leadInterval = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
var dt = (Date.now() - this._lastUpdate) / 1000.0;
|
|
var t = this.current.media.currentTime;
|
|
|
|
/* Transition from lead-in to playback */
|
|
if (t < 0 && (t + dt) >= 0) {
|
|
this.current.media.currentTime = 0;
|
|
this.current.media.paused = false;
|
|
this._counter = 0;
|
|
this._lastUpdate = Date.now();
|
|
this.sendMediaUpdate(this.channel.users);
|
|
return;
|
|
}
|
|
|
|
this.current.media.currentTime += dt;
|
|
this._lastUpdate = Date.now();
|
|
this._counter++;
|
|
|
|
/**
|
|
* Don't transition until 2 seconds after the end, to allow slightly
|
|
* off-sync clients to catch up
|
|
*/
|
|
if (this.current.media.currentTime >= this.current.media.seconds + 2) {
|
|
this._playNext();
|
|
} else if(this._counter % UPDATE_INTERVAL == 0) {
|
|
this.sendMediaUpdate(this.channel.users);
|
|
}
|
|
};
|
|
|
|
PlaylistModule.prototype._playNext = function () {
|
|
if (!this.current) {
|
|
return;
|
|
}
|
|
|
|
var next = this.current.next || this.items.first;
|
|
|
|
if (this.current.temp) {
|
|
/* The _delete handler will take care of starting the next video */
|
|
this._delete(this.current.uid);
|
|
} else if (next) {
|
|
this.current = next;
|
|
this.startPlayback();
|
|
}
|
|
};
|
|
|
|
PlaylistModule.prototype.clean = function (test) {
|
|
var self = this;
|
|
var matches = self.items.findAll(test);
|
|
matches.forEach(function (m) {
|
|
self._delete(m.uid);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* == Command Handlers ==
|
|
*/
|
|
|
|
function generateTargetRegex(target) {
|
|
const flagsre = /^(-[img]+\s+)/i
|
|
var m = target.match(flagsre);
|
|
var flags = "";
|
|
if (m) {
|
|
flags = m[0].slice(1,-1);
|
|
target = target.replace(flagsre, "");
|
|
}
|
|
return new RegExp(target, flags);
|
|
}
|
|
|
|
PlaylistModule.prototype.handleClean = function (user, msg, meta) {
|
|
if (!this.channel.modules.permissions.canDeleteVideo(user)) {
|
|
return;
|
|
}
|
|
|
|
var args = msg.split(" ");
|
|
var cmd = args.shift();
|
|
if (args.length === 0) {
|
|
return user.socket.emit("errorMsg", {
|
|
msg: "No target given for " + cmd + ". Usage: /clean <username> or " +
|
|
"/cleantitle <title>"
|
|
});
|
|
}
|
|
var target = generateTargetRegex(args.join(" "));
|
|
|
|
this.channel.logger.log("[playlist] " + user.getName() + " used " + cmd +
|
|
" with target regex: " + target);
|
|
|
|
var cleanfn;
|
|
if (cmd === "/clean") {
|
|
cleanfn = function (item) { return target.test(item.queueby); };
|
|
} else if (cmd === "/cleantitle") {
|
|
cleanfn = function (item) { return target.exec(item.media.title) !== null; };
|
|
}
|
|
|
|
this.clean(cleanfn);
|
|
};
|
|
|
|
/**
|
|
* == User playlist stuff ==
|
|
*/
|
|
PlaylistModule.prototype.handleListPlaylists = function (user) {
|
|
if (!user.is(Flags.U_REGISTERED)) {
|
|
return user.socket.emit("errorMsg", {
|
|
msg: "Only registered users can use the user playlist function."
|
|
});
|
|
}
|
|
|
|
db.listUserPlaylists(user.getName(), function (err, rows) {
|
|
if (err) {
|
|
user.socket.emit("errorMsg", {
|
|
msg: "Database error when attempting to fetch list of playlists"
|
|
});
|
|
return;
|
|
}
|
|
|
|
user.socket.emit("listPlaylists", rows);
|
|
});
|
|
};
|
|
|
|
PlaylistModule.prototype.handleClonePlaylist = function (user, data) {
|
|
if (!user.is(Flags.U_REGISTERED)) {
|
|
return user.socket.emit("errorMsg", {
|
|
msg: "Only registered users can use the user playlist function."
|
|
});
|
|
}
|
|
|
|
if (!this.channel.modules.permissions.canSeePlaylist(user)) {
|
|
return user.socket.emit("errorMsg", {
|
|
msg: "You are not allowed to save this playlist"
|
|
});
|
|
}
|
|
|
|
data.name = data.name.replace(/[^\u0000-\uffff]/g, "?");
|
|
|
|
var pl = this.items.toArray();
|
|
var self = this;
|
|
db.saveUserPlaylist(pl, user.getName(), data.name, function (err) {
|
|
if (err) {
|
|
user.socket.emit("errorMsg", {
|
|
msg: "Database error when saving playlist"
|
|
});
|
|
return;
|
|
}
|
|
|
|
self.handleListPlaylists(user);
|
|
});
|
|
};
|
|
|
|
PlaylistModule.prototype.handleDeletePlaylist = function (user, data) {
|
|
if (!user.is(Flags.U_REGISTERED)) {
|
|
return user.socket.emit("errorMsg", {
|
|
msg: "Only registered users can use the user playlist function."
|
|
});
|
|
}
|
|
|
|
var self = this;
|
|
db.deleteUserPlaylist(user.getName(), data.name, function (err) {
|
|
if (err) {
|
|
user.socket.emit("errorMsg", {
|
|
msg: "Database error when deleting playlist"
|
|
});
|
|
return;
|
|
}
|
|
|
|
self.handleListPlaylists(user);
|
|
});
|
|
};
|
|
|
|
PlaylistModule.prototype.handleQueuePlaylist = function (user, data) {
|
|
var perms = this.channel.modules.permissions;
|
|
|
|
if (!perms.canAddList(user)) {
|
|
return;
|
|
}
|
|
|
|
if (data.pos !== "next" && data.pos !== "end") {
|
|
return;
|
|
}
|
|
|
|
if (data.pos === "next" && !perms.canAddNext(user)) {
|
|
return;
|
|
}
|
|
|
|
var temp = data.temp || !perms.canAddNonTemp(user);
|
|
var maxlength = 0;
|
|
if (!perms.canExceedMaxLength(user)) {
|
|
if (this.channel.modules.options) {
|
|
maxlength = this.channel.modules.options.get("maxlength");
|
|
}
|
|
}
|
|
var qdata = {
|
|
temp: temp,
|
|
queueby: user.getName(),
|
|
maxlength: maxlength,
|
|
pos: data.pos
|
|
};
|
|
|
|
const self = this;
|
|
self.channel.refCounter.ref("PlaylistModule::handleQueuePlaylist");
|
|
db.getUserPlaylist(user.getName(), data.name, function (err, pl) {
|
|
if (err) {
|
|
self.channel.refCounter.unref("PlaylistModule::handleQueuePlaylist");
|
|
return user.socket.emit("errorMsg", {
|
|
msg: "Playlist load failed: " + err
|
|
});
|
|
}
|
|
|
|
try {
|
|
if (data.pos === "next") {
|
|
pl.reverse();
|
|
if (pl.length > 0 && self.meta.count === 0) {
|
|
pl.unshift(pl.pop());
|
|
}
|
|
}
|
|
|
|
/* Ancient playlists don't have full data */
|
|
if (pl.length > 0 && !pl[0].hasOwnProperty("title")) {
|
|
pl.forEach(function (item) {
|
|
self.handleQueue(user, {
|
|
id: item.id,
|
|
type: item.type,
|
|
pos: data.pos,
|
|
temp: temp
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
pl.forEach(function (item) {
|
|
var m = new Media(item.id, item.title, item.seconds, item.type, item.meta);
|
|
self._addItem(m, qdata, user);
|
|
});
|
|
} catch (e) {
|
|
LOGGER.error("Loading user playlist failed!");
|
|
LOGGER.error("PL: " + user.getName() + "-" + data.name);
|
|
LOGGER.error(e.stack);
|
|
user.socket.emit("queueFail", {
|
|
msg: "Internal error occurred when loading playlist.",
|
|
link: null
|
|
});
|
|
} finally {
|
|
self.channel.refCounter.unref("PlaylistModule::handleQueuePlaylist");
|
|
}
|
|
});
|
|
};
|
|
|
|
PlaylistModule.prototype.handleRequestPlaylist = function (user) {
|
|
if (user.reqPlaylistLimiter.throttle(REQ_PLAYLIST_THROTTLE)) {
|
|
user.socket.emit("errorMsg", {
|
|
msg: "Get Playlist URLs is limited to 1 usage every 60 seconds. " +
|
|
"Please try again later.",
|
|
code: "REQ_PLAYLIST_LIMIT_REACHED"
|
|
});
|
|
} else {
|
|
this.sendPlaylist([user]);
|
|
}
|
|
};
|
|
|
|
module.exports = PlaylistModule;
|