Skip to content

Commit

Permalink
Add booth auto-leave after current play (#600)
Browse files Browse the repository at this point in the history
* Add booth auto-leave after current play

The current DJ can enable auto-leave to leave the waitlist *after* their
current play is over.

* Allow disabling auto-leave
  • Loading branch information
goto-bus-stop committed Aug 23, 2024
1 parent f691112 commit 0182c35
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 19 deletions.
38 changes: 38 additions & 0 deletions src/controllers/booth.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
HistoryEntryNotFoundError,
PlaylistNotFoundError,
CannotSelfFavoriteError,
UserNotFoundError,
} from '../errors/index.js';
import getOffsetPagination from '../utils/getOffsetPagination.js';
import toItemResponse from '../utils/toItemResponse.js';
Expand Down Expand Up @@ -49,6 +50,12 @@ async function getBooth(req) {
const uw = req.uwave;

const data = await getBoothData(uw);
if (data && req.user && data.userID === req.user.id) {
return toItemResponse({
...data,
autoLeave: await uw.booth.getRemoveAfterCurrentPlay(req.user),
}, { url: req.fullUrl });
}

return toItemResponse(data, { url: req.fullUrl });
}
Expand Down Expand Up @@ -125,6 +132,36 @@ async function skipBooth(req) {
return toItemResponse({});
}

/** @typedef {{ userID: string, autoLeave: boolean }} LeaveBoothBody */

/**
* @type {import('../types.js').AuthenticatedController<{}, {}, LeaveBoothBody>}
*/
async function leaveBooth(req) {
const { user: self } = req;
const { userID, autoLeave } = req.body;
const { acl, booth, users } = req.uwave;

const skippingSelf = userID === self.id;

if (skippingSelf) {
const value = await booth.setRemoveAfterCurrentPlay(self, autoLeave);
return toItemResponse({ autoLeave: value });
}

if (!await acl.isAllowed(self, 'booth.skip.other')) {
throw new PermissionError({ requiredRole: 'booth.skip.other' });
}

const user = await users.getUser(userID);
if (!user) {
throw new UserNotFoundError({ id: userID });
}

const value = await booth.setRemoveAfterCurrentPlay(user, autoLeave);
return toItemResponse({ autoLeave: value });
}

/**
* @typedef {object} ReplaceBoothBody
* @prop {string} userID
Expand Down Expand Up @@ -373,6 +410,7 @@ export {
getBoothData,
getHistory,
getVote,
leaveBooth,
replaceBooth,
skipBooth,
socketVote,
Expand Down
102 changes: 83 additions & 19 deletions src/plugins/booth.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,36 @@ const { omit } = lodash;
* & PopulateUser & PopulatePlaylist & PopulateMedia} PopulatedHistoryEntry
*/

const REDIS_ADVANCING = 'booth:advancing';
const REDIS_HISTORY_ID = 'booth:historyID';
const REDIS_CURRENT_DJ_ID = 'booth:currentDJ';
const REDIS_REMOVE_AFTER_CURRENT_PLAY = 'booth:removeAfterCurrentPlay';
const REDIS_UPVOTES = 'booth:upvotes';
const REDIS_DOWNVOTES = 'booth:downvotes';
const REDIS_FAVORITES = 'booth:favorites';

const REMOVE_AFTER_CURRENT_PLAY_SCRIPT = {
keys: [REDIS_CURRENT_DJ_ID, REDIS_REMOVE_AFTER_CURRENT_PLAY],
lua: `
local k_dj = KEYS[1]
local k_remove = KEYS[2]
local user_id = ARGV[1]
local value = ARGV[2]
local current_dj_id = redis.call('GET', k_dj)
if current_dj_id == user_id then
if value == 'true' then
redis.call('SET', k_remove, 'true')
return 1
else
redis.call('DEL', k_remove)
return 0
end
else
return redis.error_reply('You are not currently playing')
end
`,
};

/**
* @param {Playlist} playlist
* @returns {Promise<void>}
Expand Down Expand Up @@ -53,6 +83,11 @@ class Booth {
this.#uw = uw;
this.#locker = new RedLock([this.#uw.redis]);
this.#logger = uw.logger.child({ ns: 'uwave:booth' });

uw.redis.defineCommand('uw:removeAfterCurrentPlay', {
numberOfKeys: REMOVE_AFTER_CURRENT_PLAY_SCRIPT.keys.length,
lua: REMOVE_AFTER_CURRENT_PLAY_SCRIPT.lua,
});
}

/** @internal */
Expand Down Expand Up @@ -96,7 +131,7 @@ class Booth {
*/
async getCurrentEntry() {
const { HistoryEntry } = this.#uw.models;
const historyID = await this.#uw.redis.get('booth:historyID');
const historyID = await this.#uw.redis.get(REDIS_HISTORY_ID);
if (!historyID) {
return null;
}
Expand All @@ -113,9 +148,9 @@ class Booth {
const { redis } = this.#uw;

const results = await redis.pipeline()
.smembers('booth:upvotes')
.smembers('booth:downvotes')
.smembers('booth:favorites')
.smembers(REDIS_UPVOTES)
.smembers(REDIS_DOWNVOTES)
.smembers(REDIS_FAVORITES)
.exec();
assert(results);

Expand Down Expand Up @@ -148,7 +183,7 @@ class Booth {
let userID = await this.#uw.redis.lindex('waitlist', 0);
if (!userID && !options.remove) {
// If the waitlist is empty, the current DJ will play again immediately.
userID = await this.#uw.redis.get('booth:currentDJ');
userID = await this.#uw.redis.get(REDIS_CURRENT_DJ_ID);
}
if (!userID) {
return null;
Expand Down Expand Up @@ -215,24 +250,25 @@ class Booth {
}
}

clear() {
return this.#uw.redis.del(
'booth:historyID',
'booth:currentDJ',
'booth:upvotes',
'booth:downvotes',
'booth:favorites',
async clear() {
await this.#uw.redis.del(
REDIS_HISTORY_ID,
REDIS_CURRENT_DJ_ID,
REDIS_REMOVE_AFTER_CURRENT_PLAY,
REDIS_UPVOTES,
REDIS_DOWNVOTES,
REDIS_FAVORITES,
);
}

/**
* @param {PopulatedHistoryEntry} next
*/
#update(next) {
return this.#uw.redis.multi()
.del('booth:upvotes', 'booth:downvotes', 'booth:favorites')
.set('booth:historyID', next.id)
.set('booth:currentDJ', next.user.id)
async #update(next) {
await this.#uw.redis.multi()
.del(REDIS_UPVOTES, REDIS_DOWNVOTES, REDIS_FAVORITES, REDIS_REMOVE_AFTER_CURRENT_PLAY)
.set(REDIS_HISTORY_ID, next.id)
.set(REDIS_CURRENT_DJ_ID, next.user.id)
.exec();
}

Expand Down Expand Up @@ -335,7 +371,8 @@ class Booth {
*/
async #advanceLocked(opts = {}) {
const publish = opts.publish ?? true;
const remove = opts.remove || (
const removeAfterCurrent = (await this.#uw.redis.del(REDIS_REMOVE_AFTER_CURRENT_PLAY)) === 1;
const remove = opts.remove || removeAfterCurrent || (
!await this.#uw.waitlist.isCycleEnabled()
);

Expand Down Expand Up @@ -409,13 +446,40 @@ class Booth {
*/
advance(opts = {}) {
const result = this.#locker.using(
['booth:advancing'],
[REDIS_ADVANCING],
10_000,
(signal) => this.#advanceLocked({ ...opts, signal }),
);
this.#awaitAdvance = result;
return result;
}

/**
* @param {User} user
* @param {boolean} remove
*/
async setRemoveAfterCurrentPlay(user, remove) {
const newValue = await this.#uw.redis['uw:removeAfterCurrentPlay'](
...REMOVE_AFTER_CURRENT_PLAY_SCRIPT.keys,
user._id.toString(),
remove,
);
return newValue === 1;
}

/**
* @param {User} user
*/
async getRemoveAfterCurrentPlay(user) {
const [currentDJ, removeAfterCurrentPlay] = await this.#uw.redis.mget(
REDIS_CURRENT_DJ_ID,
REDIS_REMOVE_AFTER_CURRENT_PLAY,
);
if (currentDJ === user.id) {
return removeAfterCurrentPlay != null;
}
return null;
}
}

/**
Expand Down
7 changes: 7 additions & 0 deletions src/routes/booth.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ function boothRoutes() {
schema(validations.skipBooth),
route(controller.skipBooth),
)
// PUT /booth/leave - Auto-remove the current DJ on the next advance.
.put(
'/leave',
protect(),
schema(validations.leaveBooth),
route(controller.leaveBooth),
)
// POST /booth/replace - Replace the current DJ with someone else.
.post(
'/replace',
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ declare module 'ioredis' {
'uw:addToWaitlist'(...args: [...keys: string[], userId: string]): Promise<string[]>;
/** Run the move-waitlist script, declared in src/plugins/waitlist.js. */
'uw:moveWaitlist'(...args: [...keys: string[], userId: string, position: number]): Promise<string[]>;
/** Run the remove-after-current-play script, declared in src/plugins/booth.js. */
'uw:removeAfterCurrentPlay'(...args: [...keys: string[], userId: string, remove: boolean]): Promise<0 | 1>;
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/validations.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ export const skipBooth = /** @type {const} */ ({
},
});

export const leaveBooth = /** @type {const} */ ({
body: {
type: 'object',
properties: {
userID: { $ref: 'https://ns.u-wave.net/schemas/definitions.json#/definitions/ObjectID' },
autoLeave: { type: 'boolean', default: true },
},
},
});

export const replaceBooth = /** @type {const} */ ({
body: {
type: 'object',
Expand Down

0 comments on commit 0182c35

Please sign in to comment.