• 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

86.55
/src/plugins/playlists.js
1
import ObjectGroupBy from 'object.groupby';
1✔
2
import {
1✔
3
  PlaylistNotFoundError,
1✔
4
  ItemNotInPlaylistError,
1✔
5
  MediaNotFoundError,
1✔
6
  UserNotFoundError,
1✔
7
} from '../errors/index.js';
1✔
8
import Page from '../Page.js';
1✔
9
import routes from '../routes/playlists.js';
1✔
10
import { randomUUID } from 'node:crypto';
1✔
11
import { sql } from 'kysely';
1✔
12
import {
1✔
13
  arrayCycle, jsonb, jsonEach, jsonLength, arrayShuffle as arrayShuffle,
1✔
14
} from '../utils/sqlite.js';
1✔
15
import Multimap from '../utils/Multimap.js';
1✔
16

1✔
17
/**
1✔
18
 * @typedef {import('../schema.js').UserID} UserID
1✔
19
 * @typedef {import('../schema.js').MediaID} MediaID
1✔
20
 * @typedef {import('../schema.js').PlaylistID} PlaylistID
1✔
21
 * @typedef {import('../schema.js').PlaylistItemID} PlaylistItemID
1✔
22
 * @typedef {import('../schema.js').User} User
1✔
23
 * @typedef {import('../schema.js').Playlist} Playlist
1✔
24
 * @typedef {import('../schema.js').PlaylistItem} PlaylistItem
1✔
25
 * @typedef {import('../schema.js').Media} Media
1✔
26
 */
1✔
27

1✔
28
/**
1✔
29
 * @typedef {object} PlaylistItemDesc
1✔
30
 * @prop {string} sourceType
1✔
31
 * @prop {string} sourceID
1✔
32
 * @prop {string} [artist]
1✔
33
 * @prop {string} [title]
1✔
34
 * @prop {number} [start]
1✔
35
 * @prop {number} [end]
1✔
36
 */
1✔
37

1✔
38
/**
1✔
39
 * Calculate valid start/end times for a playlist item.
1✔
40
 *
1✔
41
 * @param {PlaylistItemDesc} item
1✔
42
 * @param {Media} media
1✔
43
 */
1✔
44
function getStartEnd(item, media) {
1,340✔
45
  let { start, end } = item;
1,340✔
46
  if (!start || start < 0) {
1,340!
47
    start = 0;
1,340✔
48
  } else if (start > media.duration) {
1,340!
49
    start = media.duration;
×
50
  }
×
51
  if (!end || end > media.duration) {
1,340!
52
    end = media.duration;
1,340✔
53
  } else if (end < start) {
1,340!
54
    end = start;
×
55
  }
×
56
  return { start, end };
1,340✔
57
}
1,340✔
58

1✔
59
const playlistItemSelection = /** @type {const} */ ([
1✔
60
  'playlistItems.id as id',
1✔
61
  'media.id as media.id',
1✔
62
  'media.sourceID as media.sourceID',
1✔
63
  'media.sourceType as media.sourceType',
1✔
64
  'media.sourceData as media.sourceData',
1✔
65
  'media.artist as media.artist',
1✔
66
  'media.title as media.title',
1✔
67
  'media.duration as media.duration',
1✔
68
  'media.thumbnail as media.thumbnail',
1✔
69
  'playlistItems.artist',
1✔
70
  'playlistItems.title',
1✔
71
  'playlistItems.start',
1✔
72
  'playlistItems.end',
1✔
73
  'playlistItems.createdAt',
1✔
74
  'playlistItems.updatedAt',
1✔
75
]);
1✔
76

1✔
77
/**
1✔
78
 * @param {{
1✔
79
 *   id: PlaylistItemID,
1✔
80
 *   'media.id': MediaID,
1✔
81
 *   'media.sourceID': string,
1✔
82
 *   'media.sourceType': string,
1✔
83
 *   'media.sourceData': import('type-fest').JsonObject | null,
1✔
84
 *   'media.artist': string,
1✔
85
 *   'media.title': string,
1✔
86
 *   'media.duration': number,
1✔
87
 *   'media.thumbnail': string,
1✔
88
 *   artist: string,
1✔
89
 *   title: string,
1✔
90
 *   start: number,
1✔
91
 *   end: number,
1✔
92
 * }} raw
1✔
93
 */
1✔
94
function playlistItemFromSelection(raw) {
237✔
95
  return {
237✔
96
    _id: raw.id,
237✔
97
    artist: raw.artist,
237✔
98
    title: raw.title,
237✔
99
    start: raw.start,
237✔
100
    end: raw.end,
237✔
101
    media: {
237✔
102
      _id: raw['media.id'],
237✔
103
      artist: raw['media.artist'],
237✔
104
      title: raw['media.title'],
237✔
105
      duration: raw['media.duration'],
237✔
106
      thumbnail: raw['media.thumbnail'],
237✔
107
      sourceID: raw['media.sourceID'],
237✔
108
      sourceType: raw['media.sourceType'],
237✔
109
      sourceData: raw['media.sourceData'],
237✔
110
    },
237✔
111
  };
237✔
112
}
237✔
113

1✔
114
/**
1✔
115
 * @param {{
1✔
116
 *   id: PlaylistItemID,
1✔
117
 *   'media.id': MediaID,
1✔
118
 *   'media.sourceID': string,
1✔
119
 *   'media.sourceType': string,
1✔
120
 *   'media.sourceData': import('type-fest').JsonObject | null,
1✔
121
 *   'media.artist': string,
1✔
122
 *   'media.title': string,
1✔
123
 *   'media.duration': number,
1✔
124
 *   'media.thumbnail': string,
1✔
125
 *   artist: string,
1✔
126
 *   title: string,
1✔
127
 *   start: number,
1✔
128
 *   end: number,
1✔
129
 *   createdAt: Date,
1✔
130
 *   updatedAt: Date,
1✔
131
 * }} raw
1✔
132
 */
1✔
133
function playlistItemFromSelectionNew(raw) {
5✔
134
  return {
5✔
135
    playlistItem: {
5✔
136
      id: raw.id,
5✔
137
      mediaID: raw['media.id'],
5✔
138
      artist: raw.artist,
5✔
139
      title: raw.title,
5✔
140
      start: raw.start,
5✔
141
      end: raw.end,
5✔
142
      createdAt: raw.createdAt,
5✔
143
      updatedAt: raw.updatedAt,
5✔
144
    },
5✔
145
    media: {
5✔
146
      id: raw['media.id'],
5✔
147
      artist: raw['media.artist'],
5✔
148
      title: raw['media.title'],
5✔
149
      duration: raw['media.duration'],
5✔
150
      thumbnail: raw['media.thumbnail'],
5✔
151
      sourceID: raw['media.sourceID'],
5✔
152
      sourceType: raw['media.sourceType'],
5✔
153
      sourceData: raw['media.sourceData'],
5✔
154
    },
5✔
155
  };
5✔
156
}
5✔
157

1✔
158
class PlaylistsRepository {
92✔
159
  #uw;
92✔
160

92✔
161
  #logger;
92✔
162

92✔
163
  /**
92✔
164
   * @param {import('../Uwave.js').default} uw
92✔
165
   */
92✔
166
  constructor(uw) {
92✔
167
    this.#uw = uw;
92✔
168
    this.#logger = uw.logger.child({ ns: 'uwave:playlists' });
92✔
169
  }
92✔
170

92✔
171
  /**
92✔
172
   * @param {User} user
92✔
173
   * @param {PlaylistID} id
92✔
174
   */
92✔
175
  async getUserPlaylist(user, id) {
92✔
176
    const { db } = this.#uw;
56✔
177

56✔
178
    const playlist = await db.selectFrom('playlists')
56✔
179
      .where('userID', '=', user.id)
56✔
180
      .where('id', '=', id)
56✔
181
      .select([
56✔
182
        'id',
56✔
183
        'userID',
56✔
184
        'name',
56✔
185
        'createdAt',
56✔
186
        'updatedAt',
56✔
187
        (eb) => jsonLength(eb.ref('items')).as('size'),
56✔
188
      ])
56✔
189
      .executeTakeFirst();
56✔
190

56✔
191
    if (!playlist) {
56✔
192
      throw new PlaylistNotFoundError({ id });
6✔
193
    }
6✔
194
    return {
50✔
195
      ...playlist,
50✔
196
      size: Number(playlist.size),
50✔
197
    };
50✔
198
  }
56✔
199

92✔
200
  /**
92✔
201
   * @param {User} user
92✔
202
   * @param {{ name: string }} options
92✔
203
   */
92✔
204
  async createPlaylist(user, { name }) {
92✔
205
    const { db } = this.#uw;
42✔
206
    const id = /** @type {PlaylistID} */ (randomUUID());
42✔
207

42✔
208
    const playlist = await db.insertInto('playlists')
42✔
209
      .values({
42✔
210
        id,
42✔
211
        name,
42✔
212
        userID: user.id,
42✔
213
        items: jsonb([]),
42✔
214
      })
42✔
215
      .returning([
42✔
216
        'id',
42✔
217
        'userID',
42✔
218
        'name',
42✔
219
        (eb) => jsonLength(eb.ref('items')).as('size'),
42✔
220
        'createdAt',
42✔
221
        'updatedAt',
42✔
222
      ])
42✔
223
      .executeTakeFirstOrThrow();
42✔
224

42✔
225
    let active = false;
42✔
226
    // If this is the user's first playlist, immediately activate it.
42✔
227
    if (user.activePlaylistID == null) {
42✔
228
      this.#logger.info({ userId: user.id, playlistId: playlist.id }, 'activating first playlist');
39✔
229
      await db.updateTable('users')
39✔
230
        .where('users.id', '=', user.id)
39✔
231
        .set({ activePlaylistID: playlist.id })
39✔
232
        .execute();
39✔
233
      active = true;
39✔
234
    }
39✔
235

42✔
236
    return { playlist, active };
42✔
237
  }
42✔
238

92✔
239
  /**
92✔
240
   * @param {User} user
92✔
241
   */
92✔
242
  async getUserPlaylists(user) {
92✔
243
    const { db } = this.#uw;
3✔
244

3✔
245
    const playlists = await db.selectFrom('playlists')
3✔
246
      .where('userID', '=', user.id)
3✔
247
      .select([
3✔
248
        'id',
3✔
249
        'userID',
3✔
250
        'name',
3✔
251
        (eb) => jsonLength(eb.ref('items')).as('size'),
3✔
252
        'createdAt',
3✔
253
        'updatedAt',
3✔
254
      ])
3✔
255
      .execute();
3✔
256

3✔
257
    return playlists.map((playlist) => {
3✔
258
      return { ...playlist, size: Number(playlist.size) };
3✔
259
    });
3✔
260
  }
3✔
261

92✔
262
  /**
92✔
263
   * @param {Playlist} playlist
92✔
264
   * @param {Partial<Pick<Playlist, 'name'>>} patch
92✔
265
   */
92✔
266
  async updatePlaylist(playlist, patch = {}) {
92✔
267
    const { db } = this.#uw;
2✔
268

2✔
269
    const updatedPlaylist = await db.updateTable('playlists')
2✔
270
      .where('id', '=', playlist.id)
2✔
271
      .set(patch)
2✔
272
      .returning([
2✔
273
        'id',
2✔
274
        'userID',
2✔
275
        'name',
2✔
276
        (eb) => jsonLength(eb.ref('items')).as('size'),
2✔
277
        'createdAt',
2✔
278
        'updatedAt',
2✔
279
      ])
2✔
280
      .executeTakeFirstOrThrow();
2✔
281

2✔
282
    return updatedPlaylist;
2✔
283
  }
2✔
284

92✔
285
  /**
92✔
286
   * "Cycle" the playlist, moving its first item to last.
92✔
287
   *
92✔
288
   * @param {Playlist} playlist
92✔
289
   */
92✔
290
  async cyclePlaylist(playlist, tx = this.#uw.db) {
92✔
291
    await tx.updateTable('playlists')
5✔
292
      .where('id', '=', playlist.id)
5✔
293
      .set('items', (eb) => arrayCycle(eb.ref('items')))
5✔
294
      .execute();
5✔
295
  }
5✔
296

92✔
297
  /**
92✔
298
   * @param {Playlist} playlist
92✔
299
   */
92✔
300
  async shufflePlaylist(playlist) {
92✔
301
    const { db } = this.#uw;
1✔
302

1✔
303
    await db.updateTable('playlists')
1✔
304
      .where('id', '=', playlist.id)
1✔
305
      .set('items', (eb) => arrayShuffle(eb.ref('items')))
1✔
306
      .execute();
1✔
307
  }
1✔
308

92✔
309
  /**
92✔
310
   * @param {Playlist} playlist
92✔
311
   */
92✔
312
  async deletePlaylist(playlist) {
92✔
NEW
313
    const { db } = this.#uw;
×
NEW
314

×
NEW
315
    await db.deleteFrom('playlists')
×
NEW
316
      .where('id', '=', playlist.id)
×
NEW
317
      .execute();
×
UNCOV
318
  }
×
319

92✔
320
  /**
92✔
321
   * @param {Playlist} playlist
92✔
322
   * @param {PlaylistItemID} itemID
92✔
323
   */
92✔
324
  async getPlaylistItem(playlist, itemID) {
92✔
NEW
325
    const { db } = this.#uw;
×
326

×
NEW
327
    const raw = await db.selectFrom('playlistItems')
×
NEW
328
      .where('playlistItems.id', '=', itemID)
×
NEW
329
      .where('playlistItems.playlistID', '=', playlist.id)
×
NEW
330
      .innerJoin('media', 'media.id', 'playlistItems.mediaID')
×
NEW
331
      .select(playlistItemSelection)
×
NEW
332
      .executeTakeFirst();
×
333

×
NEW
334
    if (raw == null) {
×
NEW
335
      throw new ItemNotInPlaylistError({ playlistID: playlist.id, itemID });
×
336
    }
×
337

×
NEW
338
    return playlistItemFromSelectionNew(raw);
×
NEW
339
  }
×
340

92✔
341
  /**
92✔
342
   * @param {Playlist} playlist
92✔
343
   * @param {number} order
92✔
344
   */
92✔
345
  async getPlaylistItemAt(playlist, order) {
92✔
346
    const { db } = this.#uw;
5✔
347

5✔
348
    const raw = await db.selectFrom('playlistItems')
5✔
349
      .where('playlistItems.playlistID', '=', playlist.id)
5✔
350
      .where('playlistItems.id', '=', (eb) => {
5✔
351
        /** @type {import('kysely').RawBuilder<PlaylistItemID>} */
5✔
352
        // items->>order doesn't work for some reason, not sure why
5✔
353
        const item =  sql`json_extract(items, ${`$[${order}]`})`;
5✔
354
        return eb.selectFrom('playlists')
5✔
355
          .select(item.as('playlistItemID'))
5✔
356
          .where('id', '=', playlist.id);
5✔
357
      })
5✔
358
      .innerJoin('media', 'media.id', 'playlistItems.mediaID')
5✔
359
      .select(playlistItemSelection)
5✔
360
      .executeTakeFirst();
5✔
361

5✔
362
    if (raw == null) {
5!
NEW
363
      throw new ItemNotInPlaylistError({ playlistID: playlist.id });
×
364
    }
×
365

5✔
366
    return playlistItemFromSelectionNew(raw);
5✔
367
  }
5✔
368

92✔
369
  /**
92✔
370
   * @param {{ id: PlaylistID }} playlist
92✔
371
   * @param {string|undefined} filter
92✔
372
   * @param {{ offset: number, limit: number }} pagination
92✔
373
   */
92✔
374
  async getPlaylistItems(playlist, filter, pagination) {
92✔
375
    const { db } = this.#uw;
11✔
376

11✔
377
    let query = db.selectFrom('playlists')
11✔
378
      .innerJoin(
11✔
379
        (eb) => jsonEach(eb.ref('playlists.items')).as('playlistItemIDs'),
11✔
380
        (join) => join,
11✔
381
      )
11✔
382
      .innerJoin('playlistItems', (join) => join.on((eb) => eb(
11✔
383
        eb.ref('playlistItemIDs.value'),
11✔
384
        '=',
11✔
385
        eb.ref('playlistItems.id'),
11✔
386
      )))
11✔
387
      .innerJoin('media', 'playlistItems.mediaID', 'media.id')
11✔
388
      .where('playlists.id', '=', playlist.id)
11✔
389
      .select(playlistItemSelection);
11✔
390
    if (filter != null) {
11✔
391
      query = query.where((eb) => eb.or([
2✔
392
        eb('playlistItems.artist', 'like', `%${filter}%`),
2✔
393
        eb('playlistItems.title', 'like', `%${filter}%`),
2✔
394
      ]));
2✔
395
    }
2✔
396

11✔
397
    query = query
11✔
398
      .offset(pagination.offset)
11✔
399
      .limit(pagination.limit);
11✔
400

11✔
401
    const totalQuery = db.selectFrom('playlists')
11✔
402
      .select((eb) => jsonLength(eb.ref('items')).as('count'))
11✔
403
      .where('id', '=', playlist.id)
11✔
404
      .executeTakeFirstOrThrow();
11✔
405

11✔
406
    const filteredQuery = filter == null ? totalQuery : db.selectFrom('playlistItems')
11✔
407
      .select((eb) => eb.fn.countAll().as('count'))
2✔
408
      .where('playlistID', '=', playlist.id)
2✔
409
      .where((eb) => eb.or([
2✔
410
        eb('playlistItems.artist', 'like', `%${filter}%`),
2✔
411
        eb('playlistItems.title', 'like', `%${filter}%`),
2✔
412
      ]))
2✔
413
      .executeTakeFirstOrThrow();
11✔
414

11✔
415
    const [
11✔
416
      playlistItemsRaw,
11✔
417
      filtered,
11✔
418
      total,
11✔
419
    ] = await Promise.all([
11✔
420
      query.execute(),
11✔
421
      filteredQuery,
11✔
422
      totalQuery,
11✔
423
    ]);
11✔
424

11✔
425
    const playlistItems = playlistItemsRaw.map(playlistItemFromSelection);
11✔
426

11✔
427
    return new Page(playlistItems, {
11✔
428
      pageSize: pagination.limit,
11✔
429
      filtered: Number(filtered.count),
11✔
430
      total: Number(total.count),
11✔
431

11✔
432
      current: pagination,
11✔
433
      next: {
11✔
434
        offset: pagination.offset + pagination.limit,
11✔
435
        limit: pagination.limit,
11✔
436
      },
11✔
437
      previous: {
11✔
438
        offset: Math.max(pagination.offset - pagination.limit, 0),
11✔
439
        limit: pagination.limit,
11✔
440
      },
11✔
441
    });
11✔
442
  }
11✔
443

92✔
444
  /**
92✔
445
   * Get playlists containing a particular Media.
92✔
446
   *
92✔
447
   * @typedef {object} GetPlaylistsContainingMediaOptions
92✔
448
   * @prop {UserID} [author]
92✔
449
   * @prop {string[]} [fields]
92✔
450
   * @param {MediaID} mediaID
92✔
451
   * @param {GetPlaylistsContainingMediaOptions} options
92✔
452
   */
92✔
453
  async getPlaylistsContainingMedia(mediaID, options = {}) {
92✔
NEW
454
    const { db } = this.#uw;
×
NEW
455

×
NEW
456
    let query = db.selectFrom('playlists')
×
NEW
457
      .select([
×
NEW
458
        'playlists.id',
×
NEW
459
        'playlists.userID',
×
NEW
460
        'playlists.name',
×
NEW
461
        (eb) => jsonLength(eb.ref('playlists.items')).as('size'),
×
NEW
462
        'playlists.createdAt',
×
NEW
463
        'playlists.updatedAt',
×
NEW
464
      ])
×
NEW
465
      .innerJoin('playlistItems', 'playlists.id', 'playlistItems.playlistID')
×
NEW
466
      .where('playlistItems.mediaID', '=', mediaID)
×
NEW
467
      .groupBy('playlistItems.playlistID');
×
468
    if (options.author) {
×
NEW
469
      query = query.where('playlists.userID', '=', options.author);
×
470
    }
×
471

×
NEW
472
    const playlists = await query.execute();
×
NEW
473
    return playlists;
×
UNCOV
474
  }
×
475

92✔
476
  /**
92✔
477
   * Get playlists that contain any of the given medias. If multiple medias are in a single
92✔
478
   * playlist, that playlist will be returned multiple times, keyed on the media's unique ObjectId.
92✔
479
   *
92✔
480
   * @param {MediaID[]} mediaIDs
92✔
481
   * @param {{ author?: UserID }} options
92✔
482
   * @returns A map of media IDs to the Playlist objects that contain them.
92✔
483
   */
92✔
484
  async getPlaylistsContainingAnyMedia(mediaIDs, options = {}) {
92✔
NEW
485
    const { db } = this.#uw;
×
486

×
NEW
487
    /** @type {Multimap<MediaID, Playlist>} */
×
NEW
488
    const playlistsByMediaID = new Multimap();
×
NEW
489
    if (mediaIDs.length === 0) {
×
NEW
490
      return playlistsByMediaID;
×
NEW
491
    }
×
492

×
NEW
493
    let query = db.selectFrom('playlists')
×
NEW
494
      .innerJoin('playlistItems', 'playlists.id', 'playlistItems.playlistID')
×
NEW
495
      .select([
×
NEW
496
        'playlists.id',
×
NEW
497
        'playlists.userID',
×
NEW
498
        'playlists.name',
×
NEW
499
        (eb) => jsonLength(eb.ref('playlists.items')).as('size'),
×
NEW
500
        'playlists.createdAt',
×
NEW
501
        'playlists.updatedAt',
×
NEW
502
        'playlistItems.mediaID',
×
NEW
503
      ])
×
NEW
504
      .where('playlistItems.mediaID', 'in', mediaIDs);
×
505
    if (options.author) {
×
NEW
506
      query = query.where('playlists.userID', '=', options.author);
×
507
    }
×
508

×
NEW
509
    const playlists = await query.execute();
×
NEW
510
    for (const { mediaID, ...playlist } of playlists) {
×
NEW
511
      playlistsByMediaID.set(mediaID, playlist);
×
NEW
512
    }
×
513

×
514
    return playlistsByMediaID;
×
515
  }
×
516

92✔
517
  /**
92✔
518
   * Load media for all the given source type/source IDs.
92✔
519
   *
92✔
520
   * @param {User} user
92✔
521
   * @param {{ sourceType: string, sourceID: string }[]} items
92✔
522
   */
92✔
523
  async resolveMedia(user, items) {
92✔
524
    const { db } = this.#uw;
35✔
525

35✔
526
    // Group by source so we can retrieve all unknown medias from the source in
35✔
527
    // one call.
35✔
528
    const itemsBySourceType = ObjectGroupBy(items, (item) => item.sourceType);
35✔
529
    /** @type {Map<string, Media>} */
35✔
530
    const allMedias = new Map();
35✔
531
    const promises = Object.entries(itemsBySourceType).map(async ([sourceType, sourceItems]) => {
35✔
532
      const knownMedias = await db.selectFrom('media')
31✔
533
        .where('sourceType', '=', sourceType)
31✔
534
        .where('sourceID', 'in', sourceItems.map((item) => String(item.sourceID)))
31✔
535
        .selectAll()
31✔
536
        .execute();
31✔
537

31✔
538
      /** @type {Set<string>} */
31✔
539
      const knownMediaIDs = new Set();
31✔
540
      knownMedias.forEach((knownMedia) => {
31✔
NEW
541
        allMedias.set(`${knownMedia.sourceType}:${knownMedia.sourceID}`, knownMedia);
×
UNCOV
542
        knownMediaIDs.add(knownMedia.sourceID);
×
543
      });
31✔
544

31✔
545
      /** @type {string[]} */
31✔
546
      const unknownMediaIDs = [];
31✔
547
      sourceItems.forEach((item) => {
31✔
548
        if (!knownMediaIDs.has(String(item.sourceID))) {
1,340✔
549
          unknownMediaIDs.push(String(item.sourceID));
1,340✔
550
        }
1,340✔
551
      });
31✔
552

31✔
553
      if (unknownMediaIDs.length > 0) {
31✔
554
        // @ts-expect-error TS2322
31✔
555
        const unknownMedias = await this.#uw.source(sourceType).get(user, unknownMediaIDs);
31✔
556
        for (const media of unknownMedias) {
31✔
557
          const newMedia = await db.insertInto('media')
1,340✔
558
            .values({
1,340✔
559
              id: /** @type {MediaID} */ (randomUUID()),
1,340✔
560
              sourceType: media.sourceType,
1,340✔
561
              sourceID: media.sourceID,
1,340✔
562
              sourceData: jsonb(media.sourceData),
1,340✔
563
              artist: media.artist,
1,340✔
564
              title: media.title,
1,340✔
565
              duration: media.duration,
1,340✔
566
              thumbnail: media.thumbnail,
1,340✔
567
            })
1,340✔
568
            .returningAll()
1,340✔
569
            .executeTakeFirstOrThrow();
1,340✔
570

1,340✔
571
          allMedias.set(`${media.sourceType}:${media.sourceID}`, newMedia);
1,340✔
572
        }
1,340✔
573
      }
31✔
574
    });
35✔
575

35✔
576
    await Promise.all(promises);
35✔
577

35✔
578
    for (const item of items) {
35✔
579
      if (!allMedias.has(`${item.sourceType}:${item.sourceID}`)) {
1,340!
NEW
580
        throw new MediaNotFoundError({ sourceType: item.sourceType, sourceID: item.sourceID });
×
NEW
581
      }
×
582
    }
1,340✔
583

35✔
584
    return allMedias;
35✔
585
  }
35✔
586

92✔
587
  /**
92✔
588
   * Add items to a playlist.
92✔
589
   *
92✔
590
   * @param {Playlist} playlist
92✔
591
   * @param {PlaylistItemDesc[]} items
92✔
592
   * @param {{ after: PlaylistItemID } | { at: 'start' | 'end' }} [options]
92✔
593
   */
92✔
594
  async addPlaylistItems(playlist, items, options = { at: 'end' }) {
92✔
595
    const { users } = this.#uw;
35✔
596
    const user = await users.getUser(playlist.userID);
35✔
597
    if (!user) {
35!
NEW
598
      throw new UserNotFoundError({ id: playlist.userID });
×
599
    }
×
600

35✔
601
    const medias = await this.resolveMedia(user, items);
35✔
602
    const playlistItems = items.map((item) => {
35✔
603
      const media = medias.get(`${item.sourceType}:${item.sourceID}`);
1,340✔
604
      if (media == null) {
1,340!
NEW
605
        throw new Error('resolveMedia() should have errored');
×
NEW
606
      }
×
607
      const { start, end } = getStartEnd(item, media);
1,340✔
608
      return {
1,340✔
609
        id: /** @type {PlaylistItemID} */ (randomUUID()),
1,340✔
610
        media: media,
1,340✔
611
        artist: item.artist ?? media.artist,
1,340✔
612
        title: item.title ?? media.title,
1,340✔
613
        start,
1,340✔
614
        end,
1,340✔
615
      };
1,340✔
616
    });
35✔
617

35✔
618
    const result = await this.#uw.db.transaction().execute(async (tx) => {
35✔
619
      for (const item of playlistItems) {
35✔
620
        // TODO: use a prepared statement
1,340✔
621
        await tx.insertInto('playlistItems')
1,340✔
622
          .values({
1,340✔
623
            id: item.id,
1,340✔
624
            playlistID: playlist.id,
1,340✔
625
            mediaID: item.media.id,
1,340✔
626
            artist: item.artist,
1,340✔
627
            title: item.title,
1,340✔
628
            start: item.start,
1,340✔
629
            end: item.end,
1,340✔
630
          })
1,340✔
631
          .execute();
1,340✔
632
      }
1,340✔
633

35✔
634
      const result = await tx.selectFrom('playlists')
35✔
635
        .select(sql`json(items)`.as('items'))
35✔
636
        .where('id', '=', playlist.id)
35✔
637
        .executeTakeFirstOrThrow();
35✔
638

35✔
639
      /** @type {PlaylistItemID[]} */
35✔
640
      const oldItems = result?.items ? JSON.parse(/** @type {string} */ (result.items)) : [];
35!
641

35✔
642
      /** @type {PlaylistItemID | null} */
35✔
643
      let after;
35✔
644
      let newItems;
35✔
645
      if ('after' in options) {
35✔
646
        after = options.after;
1✔
647
        const insertIndex = oldItems.indexOf(options.after);
1✔
648
        newItems = [
1✔
649
          ...oldItems.slice(0, insertIndex + 1),
1✔
650
          ...playlistItems.map((item) => item.id),
1✔
651
          ...oldItems.slice(insertIndex + 1),
1✔
652
        ];
1✔
653
      } else if (options.at === 'start') {
35✔
654
        after = null;
18✔
655
        newItems = playlistItems.map((item) => item.id).concat(oldItems);
18✔
656
      } else {
34✔
657
        newItems = oldItems.concat(playlistItems.map((item) => item.id));
16✔
658
        after = oldItems.at(-1) ?? null;
16✔
659
      }
16✔
660

35✔
661
      await tx.updateTable('playlists')
35✔
662
        .where('id', '=', playlist.id)
35✔
663
        .set({ items: jsonb(newItems) })
35✔
664
        .executeTakeFirstOrThrow();
35✔
665

35✔
666
      return {
35✔
667
        added: playlistItems,
35✔
668
        afterID: after,
35✔
669
        playlistSize: newItems.length,
35✔
670
      };
35✔
671
    });
35✔
672

35✔
673
    return result;
35✔
674
  }
35✔
675

92✔
676
  /**
92✔
677
   * @param {Omit<PlaylistItem, 'playlistID'>} item
92✔
678
   * @param {Partial<Pick<PlaylistItem, 'artist' | 'title' | 'start' | 'end'>>} patch
92✔
679
   * @returns {Promise<PlaylistItem>}
92✔
680
   */
92✔
681
  async updatePlaylistItem(item, patch = {}) {
92✔
NEW
682
    const { db } = this.#uw;
×
NEW
683

×
NEW
684
    const updatedItem = await db.updateTable('playlistItems')
×
NEW
685
      .where('id', '=', item.id)
×
NEW
686
      .set(patch)
×
NEW
687
      .returningAll()
×
NEW
688
      .executeTakeFirstOrThrow();
×
NEW
689

×
NEW
690
    return updatedItem;
×
UNCOV
691
  }
×
692

92✔
693
  /**
92✔
694
   * @param {Playlist} playlist
92✔
695
   * @param {PlaylistItemID[]} itemIDs
92✔
696
   * @param {{ after: PlaylistItemID } | { at: 'start' | 'end' }} options
92✔
697
   */
92✔
698
  async movePlaylistItems(playlist, itemIDs, options) {
92✔
699
    const { db } = this.#uw;
7✔
700

7✔
701
    await db.transaction().execute(async (tx) => {
7✔
702
      const result = await tx.selectFrom('playlists')
7✔
703
        .select(sql`json(items)`.as('items'))
7✔
704
        .where('id', '=', playlist.id)
7✔
705
        .executeTakeFirst();
7✔
706

7✔
707
      const items = result?.items ? JSON.parse(/** @type {string} */ (result.items)) : [];
7!
708
      const itemIDsInPlaylist = new Set(items);
7✔
709
      const itemIDsToMove = new Set(itemIDs.filter((itemID) => itemIDsInPlaylist.has(itemID)));
7✔
710

7✔
711
      /** @type {PlaylistItemID[]} */
7✔
712
      let newItemIDs = [];
7✔
713
      /** Index in the new item array to move the item IDs to. */
7✔
714
      let insertIndex = 0;
7✔
715
      let index = 0;
7✔
716
      for (const itemID of itemIDsInPlaylist) {
7✔
717
        if (!itemIDsToMove.has(itemID)) {
140✔
718
          index += 1;
129✔
719
          newItemIDs.push(itemID);
129✔
720
        }
129✔
721
        if ('after' in options && itemID === options.after) {
140!
NEW
722
          insertIndex = index;
×
NEW
723
        }
×
724
      }
140✔
725

7✔
726
      let after;
7✔
727
      if ('after' in options) {
7!
NEW
728
        after = options.after;
×
NEW
729
        newItemIDs = [
×
NEW
730
          ...newItemIDs.slice(0, insertIndex + 1),
×
NEW
731
          ...itemIDsToMove,
×
NEW
732
          ...newItemIDs.slice(insertIndex + 1),
×
NEW
733
        ];
×
734
      } else if (options.at === 'start') {
7✔
735
        after = null;
5✔
736
        newItemIDs = [...itemIDsToMove, ...newItemIDs];
5✔
737
      } else {
7✔
738
        newItemIDs = [...newItemIDs, ...itemIDsToMove];
2✔
739
        after = newItemIDs.at(-1) ?? null;
2!
740
      }
2✔
741

7✔
742
      await tx.updateTable('playlists')
7✔
743
        .where('id', '=', playlist.id)
7✔
744
        .set('items', jsonb(newItemIDs))
7✔
745
        .execute();
7✔
746
    });
7✔
747

7✔
748
    return {};
7✔
749
  }
7✔
750

92✔
751
  /**
92✔
752
   * @param {Playlist} playlist
92✔
753
   * @param {PlaylistItemID[]} itemIDs
92✔
754
   */
92✔
755
  async removePlaylistItems(playlist, itemIDs) {
92✔
756
    const { db } = this.#uw;
3✔
757

3✔
758
    const rows = await db.selectFrom('playlists')
3✔
759
      .innerJoin((eb) => jsonEach(eb.ref('playlists.items')).as('playlistItemIDs'), (join) => join)
3✔
760
      .select('playlistItemIDs.value as itemID')
3✔
761
      .where('playlists.id', '=', playlist.id)
3✔
762
      .execute();
3✔
763

3✔
764
    // Only remove items that are actually in this playlist.
3✔
765
    const set = new Set(itemIDs);
3✔
766
    /** @type {PlaylistItemID[]} */
3✔
767
    const toRemove = [];
3✔
768
    /** @type {PlaylistItemID[]} */
3✔
769
    const toKeep = [];
3✔
770
    rows.forEach(({ itemID }) => {
3✔
771
      if (set.has(itemID)) {
60✔
772
        toRemove.push(itemID);
11✔
773
      } else {
60✔
774
        toKeep.push(itemID);
49✔
775
      }
49✔
776
    });
3✔
777

3✔
778
    await db.transaction().execute(async (tx) => {
3✔
779
      await tx.updateTable('playlists')
3✔
780
        .where('id', '=', playlist.id)
3✔
781
        .set({ items: jsonb(toKeep) })
3✔
782
        .execute();
3✔
783
      await tx.deleteFrom('playlistItems')
3✔
784
        .where('id', 'in', toRemove)
3✔
785
        .execute();
3✔
786
    });
3✔
787
  }
3✔
788
}
92✔
789

1✔
790
/**
1✔
791
 * @param {import('../Uwave.js').default} uw
1✔
792
 */
1✔
793
async function playlistsPlugin(uw) {
92✔
794
  uw.playlists = new PlaylistsRepository(uw);
92✔
795
  uw.httpApi.use('/playlists', routes());
92✔
796
}
92✔
797

1✔
798
export default playlistsPlugin;
1✔
799
export { PlaylistsRepository };
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