• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

u-wave / core / 11085094286

28 Sep 2024 03:39PM UTC coverage: 79.715% (-0.4%) from 80.131%
11085094286

Pull #637

github

web-flow
Merge 11ccf3b06 into 14c162f19
Pull Request #637: Switch to a relational database, closes #549

751 of 918 branches covered (81.81%)

Branch coverage included in aggregate %.

1891 of 2530 new or added lines in 50 files covered. (74.74%)

13 existing lines in 7 files now uncovered.

9191 of 11554 relevant lines covered (79.55%)

68.11 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

52.55
/src/controllers/booth.js
1
import assert from 'node:assert';
1✔
2
import {
1✔
3
  HTTPError,
1✔
4
  PermissionError,
1✔
5
  HistoryEntryNotFoundError,
1✔
6
  PlaylistNotFoundError,
1✔
7
  CannotSelfFavoriteError,
1✔
8
  UserNotFoundError,
1✔
9
} from '../errors/index.js';
1✔
10
import getOffsetPagination from '../utils/getOffsetPagination.js';
1✔
11
import toItemResponse from '../utils/toItemResponse.js';
1✔
12
import toListResponse from '../utils/toListResponse.js';
1✔
13
import toPaginatedResponse from '../utils/toPaginatedResponse.js';
1✔
14
import { Permissions } from '../plugins/acl.js';
1✔
15

1✔
16
/**
1✔
17
 * @typedef {import('../schema').UserID} UserID
1✔
18
 * @typedef {import('../schema').MediaID} MediaID
1✔
19
 * @typedef {import('../schema').PlaylistID} PlaylistID
1✔
20
 * @typedef {import('../schema').HistoryEntryID} HistoryEntryID
1✔
21
 */
1✔
22

1✔
23
/**
1✔
24
 * @param {import('../Uwave.js').default} uw
1✔
25
 */
1✔
26
async function getBoothData(uw) {
4✔
27
  const { booth } = uw;
4✔
28

4✔
29
  const state = await booth.getCurrentEntry();
4✔
30
  if (state == null) {
4✔
31
    return null;
3✔
32
  }
3✔
33

1✔
34
  // @ts-expect-error TS2322: We just populated historyEntry.media.media
1✔
35
  const media = booth.getMediaForPlayback(state);
1✔
36

1✔
37
  const votes = await booth.getCurrentVoteStats();
1✔
38

1✔
39
  return {
1✔
40
    historyID: state.historyEntry.id,
1✔
41
    // playlistID: state.playlist.id,
1✔
42
    playedAt: state.historyEntry.createdAt.getTime(),
1✔
43
    userID: state.user.id,
1✔
44
    media,
1✔
45
    stats: votes,
1✔
46
  };
1✔
47
}
4✔
48

1✔
49
/**
1✔
50
 * @type {import('../types.js').Controller}
1✔
51
 */
1✔
52
async function getBooth(req) {
1✔
53
  const uw = req.uwave;
1✔
54

1✔
55
  const data = await getBoothData(uw);
1✔
56
  if (data && req.user && data.userID === req.user.id) {
1✔
57
    return toItemResponse({
1✔
58
      ...data,
1✔
59
      autoLeave: await uw.booth.getRemoveAfterCurrentPlay(req.user),
1✔
60
    }, { url: req.fullUrl });
1✔
61
  }
1✔
62

×
63
  return toItemResponse(data, { url: req.fullUrl });
×
64
}
1✔
65

1✔
66
/**
1✔
67
 * @param {import('../Uwave.js').default} uw
1✔
68
 */
1✔
69
function getCurrentDJ(uw) {
5✔
70
  return /** @type {Promise<UserID|null>} */ (uw.redis.get('booth:currentDJ'));
5✔
71
}
5✔
72

1✔
73
/**
1✔
74
 * @param {import('../Uwave.js').default} uw
1✔
75
 * @param {UserID|null} moderatorID - `null` if a user is skipping their own turn.
1✔
76
 * @param {UserID} userID
1✔
77
 * @param {string|null} reason
1✔
78
 * @param {{ remove?: boolean }} [opts]
1✔
79
 */
1✔
80
async function doSkip(uw, moderatorID, userID, reason, opts = {}) {
×
81
  uw.publish('booth:skip', {
×
82
    moderatorID,
×
83
    userID,
×
84
    reason,
×
85
  });
×
86

×
87
  await uw.booth.advance({
×
88
    remove: opts.remove === true,
×
89
  });
×
90
}
×
91

1✔
92
/**
1✔
93
 * @typedef {object} SkipUserAndReason
1✔
94
 * @prop {UserID} userID
1✔
95
 * @prop {string} reason
1✔
96
 * @typedef {{
1✔
97
 *   remove?: boolean,
1✔
98
 *   userID?: UserID,
1✔
99
 *   reason?: string,
1✔
100
 * } & (SkipUserAndReason | {})} SkipBoothBody
1✔
101
 */
1✔
102

1✔
103
/**
1✔
104
 * @type {import('../types.js').AuthenticatedController<{}, {}, SkipBoothBody>}
1✔
105
 */
1✔
106
async function skipBooth(req) {
×
107
  const { user } = req;
×
108
  const { userID, reason, remove } = req.body;
×
109
  const { acl } = req.uwave;
×
110

×
111
  const skippingSelf = (!userID && !reason) || userID === user.id;
×
112
  const opts = { remove: !!remove };
×
113

×
114
  if (skippingSelf) {
×
115
    const currentDJ = await getCurrentDJ(req.uwave);
×
116
    if (!currentDJ || currentDJ !== req.user.id) {
×
117
      throw new HTTPError(412, 'You are not currently playing');
×
118
    }
×
119

×
120
    await doSkip(req.uwave, null, req.user.id, null, opts);
×
121

×
122
    return toItemResponse({});
×
123
  }
×
124

×
NEW
125
  if (!await acl.isAllowed(user, Permissions.SkipOther)) {
×
126
    throw new PermissionError({ requiredRole: 'booth.skip.other' });
×
127
  }
×
128

×
129
  // @ts-expect-error TS2345 pretending like `userID` is definitely defined here
×
130
  // TODO I think the typescript error is actually correct so we should fix this
×
131
  await doSkip(req.uwave, user.id, userID, reason, opts);
×
132

×
133
  return toItemResponse({});
×
134
}
×
135

1✔
136
/** @typedef {{ userID: string, autoLeave: boolean }} LeaveBoothBody */
1✔
137

1✔
138
/**
1✔
139
 * @type {import('../types.js').AuthenticatedController<{}, {}, LeaveBoothBody>}
1✔
140
 */
1✔
141
async function leaveBooth(req) {
×
142
  const { user: self } = req;
×
143
  const { userID, autoLeave } = req.body;
×
144
  const { acl, booth, users } = req.uwave;
×
145

×
146
  const skippingSelf = userID === self.id;
×
147

×
148
  if (skippingSelf) {
×
149
    const value = await booth.setRemoveAfterCurrentPlay(self, autoLeave);
×
150
    return toItemResponse({ autoLeave: value });
×
151
  }
×
152

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

×
157
  const user = await users.getUser(userID);
×
158
  if (!user) {
×
159
    throw new UserNotFoundError({ id: userID });
×
160
  }
×
161

×
162
  const value = await booth.setRemoveAfterCurrentPlay(user, autoLeave);
×
163
  return toItemResponse({ autoLeave: value });
×
164
}
×
165

1✔
166
/**
1✔
167
 * @typedef {object} ReplaceBoothBody
1✔
168
 * @prop {UserID} userID
1✔
169
 */
1✔
170

1✔
171
/**
1✔
172
 * @type {import('../types.js').AuthenticatedController<{}, {}, ReplaceBoothBody>}
1✔
173
 */
1✔
174
async function replaceBooth(req) {
×
175
  const uw = req.uwave;
×
176
  const moderatorID = req.user.id;
×
177
  const { userID } = req.body;
×
178
  let waitlist = await uw.redis.lrange('waitlist', 0, -1);
×
179

×
180
  if (!waitlist.length) {
×
181
    throw new HTTPError(404, 'Waitlist is empty.');
×
182
  }
×
183

×
184
  if (waitlist.includes(userID)) {
×
185
    uw.redis.lrem('waitlist', 1, userID);
×
186
    await uw.redis.lpush('waitlist', userID);
×
187
    waitlist = await uw.redis.lrange('waitlist', 0, -1);
×
188
  }
×
189

×
190
  uw.publish('booth:replace', {
×
191
    moderatorID,
×
192
    userID,
×
193
  });
×
194

×
195
  await uw.booth.advance();
×
196

×
197
  return toItemResponse({});
×
198
}
×
199

1✔
200
/**
1✔
201
 * @param {import('../Uwave.js').default} uw
1✔
202
 * @param {UserID} userID
1✔
203
 * @param {1|-1} direction
1✔
204
 */
1✔
205
async function addVote(uw, userID, direction) {
3✔
206
  const results = await uw.redis.multi()
3✔
207
    .srem('booth:upvotes', userID)
3✔
208
    .srem('booth:downvotes', userID)
3✔
209
    .sadd(direction > 0 ? 'booth:upvotes' : 'booth:downvotes', userID)
3✔
210
    .exec();
3✔
211
  assert(results);
3✔
212

3✔
213
  const replacedUpvote = results[0][1] !== 0;
3✔
214
  const replacedDownvote = results[1][1] !== 0;
3✔
215

3✔
216
  // Replaced an upvote by an upvote or a downvote by a downvote: the vote didn't change.
3✔
217
  // We don't need to broadcast the non-change to everyone.
3✔
218
  if ((replacedUpvote && direction > 0) || (replacedDownvote && direction < 0)) {
3!
219
    return;
1✔
220
  }
1✔
221

2✔
222
  uw.publish('booth:vote', {
2✔
223
    userID, direction,
2✔
224
  });
2✔
225
}
3✔
226

1✔
227
/**
1✔
228
 * Old way of voting: over the WebSocket
1✔
229
 *
1✔
230
 * @param {import('../Uwave.js').default} uw
1✔
231
 * @param {UserID} userID
1✔
232
 * @param {1|-1} direction
1✔
233
 */
1✔
234
async function socketVote(uw, userID, direction) {
×
235
  const currentDJ = await getCurrentDJ(uw);
×
236
  if (currentDJ !== null && currentDJ !== userID) {
×
237
    const historyID = await uw.redis.get('booth:historyID');
×
238
    if (historyID === null) return;
×
239
    if (direction > 0) {
×
240
      await addVote(uw, userID, 1);
×
241
    } else {
×
242
      await addVote(uw, userID, -1);
×
243
    }
×
244
  }
×
245
}
×
246

1✔
247
/**
1✔
248
 * @typedef {object} GetVoteParams
1✔
249
 * @prop {HistoryEntryID} historyID
1✔
250
 */
1✔
251

1✔
252
/**
1✔
253
 * @type {import('../types.js').AuthenticatedController<GetVoteParams>}
1✔
254
 */
1✔
255
async function getVote(req) {
×
256
  const { uwave: uw, user } = req;
×
257
  const { historyID } = req.params;
×
258

×
259
  const [currentDJ, currentHistoryID] = await Promise.all([
×
260
    getCurrentDJ(uw),
×
261
    uw.redis.get('booth:historyID'),
×
262
  ]);
×
263
  if (currentDJ === null || currentHistoryID === null) {
×
264
    throw new HTTPError(412, 'Nobody is playing');
×
265
  }
×
266
  if (historyID && historyID !== currentHistoryID) {
×
267
    throw new HTTPError(412, 'Cannot get vote for media that is not currently playing');
×
268
  }
×
269

×
270
  const [upvoted, downvoted] = await Promise.all([
×
271
    uw.redis.sismember('booth:upvotes', user.id),
×
272
    uw.redis.sismember('booth:downvotes', user.id),
×
273
  ]);
×
274

×
275
  let direction = 0;
×
276
  if (upvoted) {
×
277
    direction = 1;
×
278
  } else if (downvoted) {
×
279
    direction = -1;
×
280
  }
×
281

×
282
  return toItemResponse({ direction });
×
283
}
×
284

1✔
285
/**
1✔
286
 * @typedef {object} VoteParams
1✔
287
 * @prop {HistoryEntryID} historyID
1✔
288
 * @typedef {object} VoteBody
1✔
289
 * @prop {1|-1} direction
1✔
290
 */
1✔
291

1✔
292
/**
1✔
293
 * @type {import('../types.js').AuthenticatedController<VoteParams, {}, VoteBody>}
1✔
294
 */
1✔
295
async function vote(req) {
5✔
296
  const { uwave: uw, user } = req;
5✔
297
  const { historyID } = req.params;
5✔
298
  const { direction } = req.body;
5✔
299

5✔
300
  const [currentDJ, currentHistoryID] = await Promise.all([
5✔
301
    getCurrentDJ(uw),
5✔
302
    uw.redis.get('booth:historyID'),
5✔
303
  ]);
5✔
304
  if (currentDJ === null || currentHistoryID === null) {
5✔
305
    throw new HTTPError(412, 'Nobody is playing');
2✔
306
  }
2✔
307
  if (currentDJ === user.id) {
5!
308
    throw new HTTPError(412, 'Cannot vote for your own plays');
×
309
  }
×
310
  if (historyID && historyID !== currentHistoryID) {
5!
311
    throw new HTTPError(412, 'Cannot vote for media that is not currently playing');
×
312
  }
×
313

3✔
314
  if (direction > 0) {
5✔
315
    await addVote(uw, user.id, 1);
1✔
316
  } else {
5✔
317
    await addVote(uw, user.id, -1);
2✔
318
  }
2✔
319

3✔
320
  return toItemResponse({});
3✔
321
}
5✔
322

1✔
323
/**
1✔
324
 * @typedef {object} FavoriteBody
1✔
325
 * @prop {PlaylistID} playlistID
1✔
326
 * @prop {HistoryEntryID} historyID
1✔
327
 */
1✔
328

1✔
329
/**
1✔
330
 * @type {import('../types.js').AuthenticatedController<{}, {}, FavoriteBody>}
1✔
331
 */
1✔
332
async function favorite(req) {
×
333
  const { user } = req;
×
334
  const { playlistID, historyID } = req.body;
×
NEW
335
  const { history, playlists } = req.uwave;
×
336
  const uw = req.uwave;
×
337

×
NEW
338
  const historyEntry = await history.getEntry(historyID);
×
339

×
340
  if (!historyEntry) {
×
341
    throw new HistoryEntryNotFoundError({ id: historyID });
×
342
  }
×
NEW
343
  if (historyEntry.user._id === user.id) {
×
344
    throw new CannotSelfFavoriteError();
×
345
  }
×
346

×
NEW
347
  const playlist = await playlists.getUserPlaylist(user, playlistID);
×
348
  if (!playlist) {
×
349
    throw new PlaylistNotFoundError({ id: playlistID });
×
350
  }
×
351

×
352
  // `.media` has the same shape as `.item`, but is guaranteed to exist and have
×
353
  // the same properties as when the playlist item was actually played.
×
NEW
354
  const result = await playlists.addPlaylistItems(
×
NEW
355
    playlist,
×
NEW
356
    [{
×
NEW
357
      sourceType: historyEntry.media.media.sourceType,
×
NEW
358
      sourceID: historyEntry.media.media.sourceID,
×
NEW
359
      artist: historyEntry.media.artist,
×
NEW
360
      title: historyEntry.media.title,
×
NEW
361
      start: historyEntry.media.start,
×
NEW
362
      end: historyEntry.media.end,
×
NEW
363
    }],
×
NEW
364
    { at: 'end' },
×
NEW
365
  );
×
366

×
367
  await uw.redis.sadd('booth:favorites', user.id);
×
368
  uw.publish('booth:favorite', {
×
369
    userID: user.id,
×
370
    playlistID,
×
371
  });
×
372

×
NEW
373
  return toListResponse(result.added, {
×
374
    meta: {
×
NEW
375
      playlistSize: result.playlistSize,
×
376
    },
×
377
    included: {
×
378
      media: ['media'],
×
379
    },
×
380
  });
×
381
}
×
382

1✔
383
/**
1✔
384
 * @typedef {object} GetRoomHistoryQuery
1✔
385
 * @prop {import('../types.js').PaginationQuery & { media?: MediaID }} [filter]
1✔
386
 */
1✔
387
/**
1✔
388
 * @type {import('../types.js').Controller<never, GetRoomHistoryQuery, never>}
1✔
389
 */
1✔
390
async function getHistory(req) {
×
391
  const filter = {};
×
392
  const pagination = getOffsetPagination(req.query, {
×
393
    defaultSize: 25,
×
394
    maxSize: 100,
×
395
  });
×
396
  const { history } = req.uwave;
×
397

×
398
  if (req.query.filter && req.query.filter.media) {
×
399
    filter['media.media'] = req.query.filter.media;
×
400
  }
×
401

×
NEW
402
  // TODO: Support filter?
×
NEW
403

×
NEW
404
  const roomHistory = await history.getRoomHistory(pagination);
×
405

×
406
  return toPaginatedResponse(roomHistory, {
×
407
    baseUrl: req.fullUrl,
×
408
    included: {
×
409
      media: ['media.media'],
×
410
      user: ['user'],
×
411
    },
×
412
  });
×
413
}
×
414

1✔
415
export {
1✔
416
  favorite,
1✔
417
  getBooth,
1✔
418
  getBoothData,
1✔
419
  getHistory,
1✔
420
  getVote,
1✔
421
  leaveBooth,
1✔
422
  replaceBooth,
1✔
423
  skipBooth,
1✔
424
  socketVote,
1✔
425
  vote,
1✔
426
};
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc