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

u-wave / core / 21184415701

20 Jan 2026 07:25PM UTC coverage: 86.925% (+0.4%) from 86.542%
21184415701

Pull #749

github

web-flow
Merge 4d01c7415 into 443e76f56
Pull Request #749: Add test for deleting playlist

1028 of 1215 branches covered (84.61%)

Branch coverage included in aggregate %.

69 of 73 new or added lines in 3 files covered. (94.52%)

8 existing lines in 1 file now uncovered.

10666 of 12238 relevant lines covered (87.15%)

108.51 hits per line

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

93.83
/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
  PlaylistActiveError,
1✔
8
} from '../errors/index.js';
1✔
9
import Page from '../Page.js';
1✔
10
import routes from '../routes/playlists.js';
1✔
11
import { randomUUID } from 'node:crypto';
1✔
12
import { sql } from 'kysely';
1✔
13
import {
1✔
14
  arrayCycle,
1✔
15
  arrayShuffle as arrayShuffle,
1✔
16
  fromJson,
1✔
17
  isForeignKeyError,
1✔
18
  json,
1✔
19
  jsonb,
1✔
20
  jsonEach,
1✔
21
  jsonLength,
1✔
22
} from '../utils/sqlite.js';
1✔
23
import Multimap from '../utils/Multimap.js';
1✔
24

1✔
25
/**
1✔
26
 * @typedef {import('../schema.js').UserID} UserID
1✔
27
 * @typedef {import('../schema.js').MediaID} MediaID
1✔
28
 * @typedef {import('../schema.js').PlaylistID} PlaylistID
1✔
29
 * @typedef {import('../schema.js').PlaylistItemID} PlaylistItemID
1✔
30
 * @typedef {import('../schema.js').User} User
1✔
31
 * @typedef {import('../schema.js').Playlist} Playlist
1✔
32
 * @typedef {import('../schema.js').PlaylistItem} PlaylistItem
1✔
33
 * @typedef {import('../schema.js').Media} Media
1✔
34
 * @typedef {import('../schema.js').Database} Database
1✔
35
 */
1✔
36

1✔
37
/**
1✔
38
 * @typedef {object} PlaylistItemDesc
1✔
39
 * @prop {string} sourceType
1✔
40
 * @prop {string} sourceID
1✔
41
 * @prop {string} [artist]
1✔
42
 * @prop {string} [title]
1✔
43
 * @prop {number} [start]
1✔
44
 * @prop {number} [end]
1✔
45
 */
1✔
46

1✔
47
/**
1✔
48
 * Calculate valid start/end times for a playlist item.
1✔
49
 *
1✔
50
 * @param {PlaylistItemDesc} item
1✔
51
 * @param {Pick<Media, 'duration'>} media
1✔
52
 */
1✔
53
function getStartEnd(item, media) {
1,401✔
54
  let { start, end } = item;
1,401✔
55
  if (!start || start < 0) {
1,401!
56
    start = 0;
1,401✔
57
  } else if (start > media.duration) {
1,401!
58
    start = media.duration;
×
59
  }
×
60
  if (!end || end > media.duration) {
1,401✔
61
    end = media.duration;
1,400✔
62
  } else if (end < start) {
1,401!
63
    end = start;
×
64
  }
×
65
  return { start, end };
1,401✔
66
}
1,401✔
67

1✔
68
const playlistItemSelection = /** @type {const} */ ([
1✔
69
  'playlistItems.id as id',
1✔
70
  'media.id as media.id',
1✔
71
  'media.sourceID as media.sourceID',
1✔
72
  'media.sourceType as media.sourceType',
1✔
73
  /** @param {import('kysely').ExpressionBuilder<Database, 'media'>} eb */
1✔
74
  (eb) => json(eb.fn.coalesce(eb.ref('media.sourceData'), jsonb(null))).as('media.sourceData'),
1✔
75
  'media.artist as media.artist',
1✔
76
  'media.title as media.title',
1✔
77
  'media.duration as media.duration',
1✔
78
  'media.thumbnail as media.thumbnail',
1✔
79
  'playlistItems.artist',
1✔
80
  'playlistItems.title',
1✔
81
  'playlistItems.start',
1✔
82
  'playlistItems.end',
1✔
83
  'playlistItems.createdAt',
1✔
84
  'playlistItems.updatedAt',
1✔
85
]);
1✔
86

1✔
87
const mediaSelection = /** @type {const} */ ([
1✔
88
  'id',
1✔
89
  'sourceType',
1✔
90
  'sourceID',
1✔
91
  /** @param {import('kysely').ExpressionBuilder<Database, 'media'>} eb */
1✔
92
  (eb) => json(eb.fn.coalesce(eb.ref('sourceData'), jsonb(null))).as('sourceData'),
1✔
93
  'artist',
1✔
94
  'title',
1✔
95
  'duration',
1✔
96
  'thumbnail',
1✔
97
]);
1✔
98

1✔
99
/**
1✔
100
 * @param {{
1✔
101
 *   id: PlaylistItemID,
1✔
102
 *   'media.id': MediaID,
1✔
103
 *   'media.sourceID': string,
1✔
104
 *   'media.sourceType': string,
1✔
105
 *   'media.sourceData': import('../utils/sqlite.js').SerializedJSON<
1✔
106
 *       import('type-fest').JsonObject | null>,
1✔
107
 *   'media.artist': string,
1✔
108
 *   'media.title': string,
1✔
109
 *   'media.duration': number,
1✔
110
 *   'media.thumbnail': string,
1✔
111
 *   artist: string,
1✔
112
 *   title: string,
1✔
113
 *   start: number,
1✔
114
 *   end: number,
1✔
115
 * }} raw
1✔
116
 */
1✔
117
function playlistItemFromSelection(raw) {
240✔
118
  return {
240✔
119
    _id: raw.id,
240✔
120
    artist: raw.artist,
240✔
121
    title: raw.title,
240✔
122
    start: raw.start,
240✔
123
    end: raw.end,
240✔
124
    media: {
240✔
125
      _id: raw['media.id'],
240✔
126
      artist: raw['media.artist'],
240✔
127
      title: raw['media.title'],
240✔
128
      duration: raw['media.duration'],
240✔
129
      thumbnail: raw['media.thumbnail'],
240✔
130
      sourceID: raw['media.sourceID'],
240✔
131
      sourceType: raw['media.sourceType'],
240✔
132
      sourceData: fromJson(raw['media.sourceData']),
240✔
133
    },
240✔
134
  };
240✔
135
}
240✔
136

1✔
137
/**
1✔
138
 * @param {{
1✔
139
 *   id: PlaylistItemID,
1✔
140
 *   'media.id': MediaID,
1✔
141
 *   'media.sourceID': string,
1✔
142
 *   'media.sourceType': string,
1✔
143
 *   'media.sourceData': import('../utils/sqlite.js').SerializedJSON<
1✔
144
 *       import('type-fest').JsonObject | null>,
1✔
145
 *   'media.artist': string,
1✔
146
 *   'media.title': string,
1✔
147
 *   'media.duration': number,
1✔
148
 *   'media.thumbnail': string,
1✔
149
 *   artist: string,
1✔
150
 *   title: string,
1✔
151
 *   start: number,
1✔
152
 *   end: number,
1✔
153
 *   createdAt: Date,
1✔
154
 *   updatedAt: Date,
1✔
155
 * }} raw
1✔
156
 */
1✔
157
function playlistItemFromSelectionNew(raw) {
18✔
158
  return {
18✔
159
    playlistItem: {
18✔
160
      id: raw.id,
18✔
161
      mediaID: raw['media.id'],
18✔
162
      artist: raw.artist,
18✔
163
      title: raw.title,
18✔
164
      start: raw.start,
18✔
165
      end: raw.end,
18✔
166
      createdAt: raw.createdAt,
18✔
167
      updatedAt: raw.updatedAt,
18✔
168
    },
18✔
169
    media: {
18✔
170
      id: raw['media.id'],
18✔
171
      artist: raw['media.artist'],
18✔
172
      title: raw['media.title'],
18✔
173
      duration: raw['media.duration'],
18✔
174
      thumbnail: raw['media.thumbnail'],
18✔
175
      sourceID: raw['media.sourceID'],
18✔
176
      sourceType: raw['media.sourceType'],
18✔
177
      sourceData: fromJson(raw['media.sourceData']),
18✔
178
    },
18✔
179
  };
18✔
180
}
18✔
181

1✔
182
/**
1✔
183
 * @param {{
1✔
184
 *   id: MediaID,
1✔
185
 *   sourceID: string,
1✔
186
 *   sourceType: string,
1✔
187
 *   sourceData: import('../utils/sqlite.js').SerializedJSON<import('type-fest').JsonObject | null>,
1✔
188
 *   artist: string,
1✔
189
 *   title: string,
1✔
190
 *   duration: number,
1✔
191
 *   thumbnail: string,
1✔
192
 * }} raw
1✔
193
 */
1✔
194
function mediaFromRow(raw) {
1,401✔
195
  return {
1,401✔
196
    id: raw.id,
1,401✔
197
    sourceID: raw.sourceID,
1,401✔
198
    sourceType: raw.sourceType,
1,401✔
199
    sourceData: fromJson(raw.sourceData),
1,401✔
200
    artist: raw.artist,
1,401✔
201
    title: raw.title,
1,401✔
202
    duration: raw.duration,
1,401✔
203
    thumbnail: raw.thumbnail,
1,401✔
204
  };
1,401✔
205
}
1,401✔
206

1✔
207
class PlaylistsRepository {
1✔
208
  #uw;
180✔
209

180✔
210
  #logger;
180✔
211

180✔
212
  /**
180✔
213
   * @param {import('../Uwave.js').default} uw
180✔
214
   */
180✔
215
  constructor(uw) {
180✔
216
    this.#uw = uw;
180✔
217
    this.#logger = uw.logger.child({ ns: 'uwave:playlists' });
180✔
218
  }
180✔
219

180✔
220
  /**
180✔
221
   * @param {User} user
180✔
222
   * @param {PlaylistID} id
180✔
223
   */
180✔
224
  async getUserPlaylist(user, id, tx = this.#uw.db) {
180✔
225
    const playlist = await tx.selectFrom('playlists')
104✔
226
      .where('userID', '=', user.id)
104✔
227
      .where('id', '=', id)
104✔
228
      .select([
104✔
229
        'id',
104✔
230
        'userID',
104✔
231
        'name',
104✔
232
        'createdAt',
104✔
233
        'updatedAt',
104✔
234
        (eb) => jsonLength(eb.ref('items')).as('size'),
104✔
235
      ])
104✔
236
      .executeTakeFirst();
104✔
237

104✔
238
    if (!playlist) {
104✔
239
      throw new PlaylistNotFoundError({ id });
9✔
240
    }
9✔
241
    return {
95✔
242
      ...playlist,
95✔
243
      size: Number(playlist.size),
95✔
244
    };
95✔
245
  }
104✔
246

180✔
247
  /**
180✔
248
   * @param {User} user
180✔
249
   * @param {{ name: string }} options
180✔
250
   */
180✔
251
  async createPlaylist(user, { name }, tx = this.#uw.db) {
180✔
252
    const id = /** @type {PlaylistID} */ (randomUUID());
77✔
253

77✔
254
    const result = await tx.transaction().execute(async (tx) => {
77✔
255
      const playlist = await tx.insertInto('playlists')
77✔
256
        .values({
77✔
257
          id,
77✔
258
          name,
77✔
259
          userID: user.id,
77✔
260
          items: jsonb([]),
77✔
261
        })
77✔
262
        .returning([
77✔
263
          'id',
77✔
264
          'userID',
77✔
265
          'name',
77✔
266
          (eb) => jsonLength(eb.ref('items')).as('size'),
77✔
267
          'createdAt',
77✔
268
          'updatedAt',
77✔
269
        ])
77✔
270
        .executeTakeFirstOrThrow();
77✔
271

77✔
272
      const updated = await tx.updateTable('users')
77✔
273
        .where('id', '=', user.id)
77✔
274
        .where('activePlaylistID', 'is', null)
77✔
275
        .set({ activePlaylistID: playlist.id })
77✔
276
        .returning(['activePlaylistID'])
77✔
277
        .executeTakeFirst();
77✔
278

77✔
279
      return {
77✔
280
        playlist,
77✔
281
        active: updated != null && updated.activePlaylistID === playlist.id,
77✔
282
      };
77✔
283
    });
77✔
284

77✔
285
    if (result.active) {
77✔
286
      this.#logger.info({ userId: user.id, playlistId: result.playlist.id }, 'activated first playlist');
71✔
287
    }
71✔
288

77✔
289
    return result;
77✔
290
  }
77✔
291

180✔
292
  /**
180✔
293
   * @param {User} user
180✔
294
   */
180✔
295
  async getUserPlaylists(user, tx = this.#uw.db) {
180✔
296
    const playlists = await tx.selectFrom('playlists')
7✔
297
      .where('userID', '=', user.id)
7✔
298
      .select([
7✔
299
        'id',
7✔
300
        'userID',
7✔
301
        'name',
7✔
302
        (eb) => jsonLength(eb.ref('items')).as('size'),
7✔
303
        'createdAt',
7✔
304
        'updatedAt',
7✔
305
      ])
7✔
306
      .execute();
7✔
307

7✔
308
    return playlists.map((playlist) => {
7✔
309
      return { ...playlist, size: Number(playlist.size) };
8✔
310
    });
7✔
311
  }
7✔
312

180✔
313
  /**
180✔
314
   * @param {Playlist} playlist
180✔
315
   * @param {Partial<Pick<Playlist, 'name'>>} patch
180✔
316
   */
180✔
317
  async updatePlaylist(playlist, patch = {}, tx = this.#uw.db) {
180✔
318
    const updatedPlaylist = await tx.updateTable('playlists')
2✔
319
      .where('id', '=', playlist.id)
2✔
320
      .set(patch)
2✔
321
      .returning([
2✔
322
        'id',
2✔
323
        'userID',
2✔
324
        'name',
2✔
325
        (eb) => jsonLength(eb.ref('items')).as('size'),
2✔
326
        'createdAt',
2✔
327
        'updatedAt',
2✔
328
      ])
2✔
329
      .executeTakeFirstOrThrow();
2✔
330

2✔
331
    return updatedPlaylist;
2✔
332
  }
2✔
333

180✔
334
  /**
180✔
335
   * "Cycle" the playlist, moving its first item to last.
180✔
336
   *
180✔
337
   * @param {Playlist} playlist
180✔
338
   */
180✔
339
  async cyclePlaylist(playlist, tx = this.#uw.db) {
180✔
340
    await tx.updateTable('playlists')
18✔
341
      .where('id', '=', playlist.id)
18✔
342
      .set('items', (eb) => arrayCycle(eb.ref('items')))
18✔
343
      .execute();
18✔
344
  }
18✔
345

180✔
346
  /**
180✔
347
   * @param {Playlist} playlist
180✔
348
   */
180✔
349
  async shufflePlaylist(playlist, tx = this.#uw.db) {
180✔
350
    await tx.updateTable('playlists')
1✔
351
      .where('id', '=', playlist.id)
1✔
352
      .set('items', (eb) => arrayShuffle(eb.ref('items')))
1✔
353
      .execute();
1✔
354
  }
1✔
355

180✔
356
  /**
180✔
357
   * Delete a playlist. An active playlist cannot be deleted.
180✔
358
   *
180✔
359
   * @param {Playlist} playlist
180✔
360
   * @returns {Promise<void>}
180✔
361
   */
180✔
362
  async deletePlaylist(playlist, tx = this.#uw.db) {
180✔
363
    // This *must* be executed in a transaction, else it would be possible for
2✔
364
    // the items to be deleted but not the playlist metadata.
2✔
365
    // Maybe it'd be better to just require the `tx` parameter, or not support
2✔
366
    // passing one in?
2✔
367
    if (!tx.isTransaction) {
2!
NEW
368
      return tx.transaction().execute((tx) => this.deletePlaylist(playlist, tx));
×
NEW
369
    }
×
370

2✔
371
    try {
2✔
372
      await tx.deleteFrom('playlistItems')
2✔
373
        .where('playlistID', '=', playlist.id)
2✔
374
        .execute();
2✔
375
      await tx.deleteFrom('playlists')
2✔
376
        .where('id', '=', playlist.id)
2✔
377
        .execute();
2✔
378
    } catch (err) {
1✔
379
      if (isForeignKeyError(err)) {
1✔
380
        throw new PlaylistActiveError();
1✔
381
      }
1✔
NEW
382
      throw err;
×
NEW
383
    }
×
384
  }
2✔
385

180✔
386
  /**
180✔
387
   * @param {Playlist} playlist
180✔
388
   * @param {PlaylistItemID} itemID
180✔
389
   */
180✔
390
  async getPlaylistItem(playlist, itemID, tx = this.#uw.db) {
180✔
UNCOV
391
    const raw = await tx.selectFrom('playlistItems')
×
392
      .where('playlistItems.id', '=', itemID)
×
393
      .where('playlistItems.playlistID', '=', playlist.id)
×
394
      .innerJoin('media', 'media.id', 'playlistItems.mediaID')
×
395
      .select(playlistItemSelection)
×
396
      .executeTakeFirst();
×
397

×
398
    if (raw == null) {
×
399
      throw new ItemNotInPlaylistError({ playlistID: playlist.id, itemID });
×
400
    }
×
401

×
402
    return playlistItemFromSelectionNew(raw);
×
403
  }
×
404

180✔
405
  /**
180✔
406
   * @param {Playlist} playlist
180✔
407
   * @param {number} order
180✔
408
   */
180✔
409
  async getPlaylistItemAt(playlist, order, tx = this.#uw.db) {
180✔
410
    const raw = await tx.selectFrom('playlistItems')
18✔
411
      .where('playlistItems.playlistID', '=', playlist.id)
18✔
412
      .where('playlistItems.id', '=', (eb) => {
18✔
413
        /** @type {import('kysely').RawBuilder<PlaylistItemID>} */
18✔
414
        // items->>order doesn't work for some reason, not sure why
18✔
415
        const item =  sql`json_extract(items, ${`$[${order}]`})`;
18✔
416
        return eb.selectFrom('playlists')
18✔
417
          .select(item.as('playlistItemID'))
18✔
418
          .where('id', '=', playlist.id);
18✔
419
      })
18✔
420
      .innerJoin('media', 'media.id', 'playlistItems.mediaID')
18✔
421
      .select(playlistItemSelection)
18✔
422
      .executeTakeFirst();
18✔
423

18✔
424
    if (raw == null) {
18!
UNCOV
425
      throw new ItemNotInPlaylistError({ playlistID: playlist.id });
×
426
    }
×
427

18✔
428
    return playlistItemFromSelectionNew(raw);
18✔
429
  }
18✔
430

180✔
431
  /**
180✔
432
   * @param {{ id: PlaylistID }} playlist
180✔
433
   * @param {string|undefined} filter
180✔
434
   * @param {{ offset: number, limit: number }} pagination
180✔
435
   */
180✔
436
  async getPlaylistItems(playlist, filter, pagination, tx = this.#uw.db) {
180✔
437
    let query = tx.selectFrom('playlists')
12✔
438
      .innerJoin(
12✔
439
        (eb) => jsonEach(eb.ref('playlists.items')).as('playlistItemIDs'),
12✔
440
        (join) => join,
12✔
441
      )
12✔
442
      .innerJoin('playlistItems', (join) => join.on((eb) => eb(
12✔
443
        eb.ref('playlistItemIDs.value'),
12✔
444
        '=',
12✔
445
        eb.ref('playlistItems.id'),
12✔
446
      )))
12✔
447
      .innerJoin('media', 'playlistItems.mediaID', 'media.id')
12✔
448
      .where('playlists.id', '=', playlist.id)
12✔
449
      .select(playlistItemSelection);
12✔
450
    if (filter != null) {
12✔
451
      query = query.where((eb) => eb.or([
2✔
452
        eb('playlistItems.artist', 'like', `%${filter}%`),
2✔
453
        eb('playlistItems.title', 'like', `%${filter}%`),
2✔
454
      ]));
2✔
455
    }
2✔
456

12✔
457
    query = query
12✔
458
      .offset(pagination.offset)
12✔
459
      .limit(pagination.limit);
12✔
460

12✔
461
    const totalQuery = tx.selectFrom('playlists')
12✔
462
      .select((eb) => jsonLength(eb.ref('items')).as('count'))
12✔
463
      .where('id', '=', playlist.id)
12✔
464
      .executeTakeFirstOrThrow();
12✔
465

12✔
466
    const filteredQuery = filter == null ? totalQuery : tx.selectFrom('playlistItems')
12✔
467
      .select((eb) => eb.fn.countAll().as('count'))
2✔
468
      .where('playlistID', '=', playlist.id)
2✔
469
      .where((eb) => eb.or([
2✔
470
        eb('playlistItems.artist', 'like', `%${filter}%`),
2✔
471
        eb('playlistItems.title', 'like', `%${filter}%`),
2✔
472
      ]))
2✔
473
      .executeTakeFirstOrThrow();
12✔
474

12✔
475
    const [
12✔
476
      playlistItemsRaw,
12✔
477
      filtered,
12✔
478
      total,
12✔
479
    ] = await Promise.all([
12✔
480
      query.execute(),
12✔
481
      filteredQuery,
12✔
482
      totalQuery,
12✔
483
    ]);
12✔
484

12✔
485
    const playlistItems = playlistItemsRaw.map(playlistItemFromSelection);
12✔
486

12✔
487
    return new Page(playlistItems, {
12✔
488
      pageSize: pagination.limit,
12✔
489
      filtered: Number(filtered.count),
12✔
490
      total: Number(total.count),
12✔
491

12✔
492
      current: pagination,
12✔
493
      next: {
12✔
494
        offset: pagination.offset + pagination.limit,
12✔
495
        limit: pagination.limit,
12✔
496
      },
12✔
497
      previous: {
12✔
498
        offset: Math.max(pagination.offset - pagination.limit, 0),
12✔
499
        limit: pagination.limit,
12✔
500
      },
12✔
501
    });
12✔
502
  }
12✔
503

180✔
504
  /**
180✔
505
   * Get playlists containing a particular Media.
180✔
506
   *
180✔
507
   * @typedef {object} GetPlaylistsContainingMediaOptions
180✔
508
   * @prop {UserID} [author]
180✔
509
   * @prop {string[]} [fields]
180✔
510
   * @param {MediaID} mediaID
180✔
511
   * @param {GetPlaylistsContainingMediaOptions} options
180✔
512
   */
180✔
513
  async getPlaylistsContainingMedia(mediaID, options = {}, tx = this.#uw.db) {
180✔
514
    let query = tx.selectFrom('playlists')
3✔
515
      .select([
3✔
516
        'playlists.id',
3✔
517
        'playlists.userID',
3✔
518
        'playlists.name',
3✔
519
        (eb) => jsonLength(eb.ref('playlists.items')).as('size'),
3✔
520
        'playlists.createdAt',
3✔
521
        'playlists.updatedAt',
3✔
522
      ])
3✔
523
      .innerJoin('playlistItems', 'playlists.id', 'playlistItems.playlistID')
3✔
524
      .where('playlistItems.mediaID', '=', mediaID)
3✔
525
      .groupBy('playlistItems.playlistID');
3✔
526
    if (options.author) {
3✔
527
      query = query.where('playlists.userID', '=', options.author);
3✔
528
    }
3✔
529

3✔
530
    const playlists = await query.execute();
3✔
531
    return playlists;
3✔
532
  }
3✔
533

180✔
534
  /**
180✔
535
   * Get playlists that contain any of the given medias. If multiple medias are in a single
180✔
536
   * playlist, that playlist will be returned multiple times, keyed on the media ID.
180✔
537
   *
180✔
538
   * @param {MediaID[]} mediaIDs
180✔
539
   * @param {{ author?: UserID }} options
180✔
540
   * @returns A map of media IDs to the Playlist objects that contain them.
180✔
541
   */
180✔
542
  async getPlaylistsContainingAnyMedia(mediaIDs, options = {}, tx = this.#uw.db) {
180✔
543
    /** @type {Multimap<MediaID, Playlist>} */
3✔
544
    const playlistsByMediaID = new Multimap();
3✔
545
    if (mediaIDs.length === 0) {
3✔
546
      return playlistsByMediaID;
1✔
547
    }
1✔
548

2✔
549
    let query = tx.selectFrom('playlists')
2✔
550
      .innerJoin('playlistItems', 'playlists.id', 'playlistItems.playlistID')
2✔
551
      .select([
2✔
552
        'playlists.id',
2✔
553
        'playlists.userID',
2✔
554
        'playlists.name',
2✔
555
        (eb) => jsonLength(eb.ref('playlists.items')).as('size'),
2✔
556
        'playlists.createdAt',
2✔
557
        'playlists.updatedAt',
2✔
558
        'playlistItems.mediaID',
2✔
559
      ])
2✔
560
      .where('playlistItems.mediaID', 'in', mediaIDs);
2✔
561
    if (options.author) {
2✔
562
      query = query.where('playlists.userID', '=', options.author);
2✔
563
    }
2✔
564

2✔
565
    const playlists = await query.execute();
2✔
566
    for (const { mediaID, ...playlist } of playlists) {
3✔
567
      playlistsByMediaID.set(mediaID, playlist);
3✔
568
    }
3✔
569

2✔
570
    return playlistsByMediaID;
2✔
571
  }
3✔
572

180✔
573
  /**
180✔
574
   * Load media for all the given source type/source IDs.
180✔
575
   *
180✔
576
   * @param {User} user
180✔
577
   * @param {{ sourceType: string, sourceID: string }[]} items
180✔
578
   */
180✔
579
  async resolveMedia(user, items) {
180✔
580
    const { db } = this.#uw;
68✔
581

68✔
582
    // Group by source so we can retrieve all unknown medias from the source in
68✔
583
    // one call.
68✔
584
    const itemsBySourceType = ObjectGroupBy(items, (item) => item.sourceType);
68✔
585
    /** @type {Map<string, Omit<Media, 'createdAt' | 'updatedAt'>>} */
68✔
586
    const allMedias = new Map();
68✔
587
    const promises = Object.entries(itemsBySourceType).map(async ([sourceType, sourceItems]) => {
68✔
588
      const knownMedias = await db.selectFrom('media')
64✔
589
        .where('sourceType', '=', sourceType)
64✔
590
        .where('sourceID', 'in', sourceItems.map((item) => String(item.sourceID)))
64✔
591
        .select(mediaSelection)
64✔
592
        .execute();
64✔
593

64✔
594
      /** @type {Set<string>} */
64✔
595
      const knownMediaIDs = new Set();
64✔
596
      knownMedias.forEach((knownMedia) => {
64✔
597
        allMedias.set(`${knownMedia.sourceType}:${knownMedia.sourceID}`, mediaFromRow(knownMedia));
9✔
598
        knownMediaIDs.add(knownMedia.sourceID);
9✔
599
      });
64✔
600

64✔
601
      /** @type {string[]} */
64✔
602
      const unknownMediaIDs = [];
64✔
603
      sourceItems.forEach((item) => {
64✔
604
        if (!knownMediaIDs.has(String(item.sourceID))) {
1,401✔
605
          unknownMediaIDs.push(String(item.sourceID));
1,392✔
606
        }
1,392✔
607
      });
64✔
608

64✔
609
      if (unknownMediaIDs.length > 0) {
64✔
610
        // @ts-expect-error TS2322
61✔
611
        const unknownMedias = await this.#uw.source(sourceType).get(user, unknownMediaIDs);
61✔
612
        for (const media of unknownMedias) {
61✔
613
          const newMedia = await db.insertInto('media')
1,392✔
614
            .values({
1,392✔
615
              id: /** @type {MediaID} */ (randomUUID()),
1,392✔
616
              sourceType: media.sourceType,
1,392✔
617
              sourceID: media.sourceID,
1,392✔
618
              sourceData: media.sourceData == null ? null : jsonb(media.sourceData),
1,392!
619
              artist: media.artist,
1,392✔
620
              title: media.title,
1,392✔
621
              duration: media.duration,
1,392✔
622
              thumbnail: media.thumbnail,
1,392✔
623
            })
1,392✔
624
            .returning(mediaSelection)
1,392✔
625
            .executeTakeFirstOrThrow();
1,392✔
626

1,392✔
627
          allMedias.set(`${media.sourceType}:${media.sourceID}`, mediaFromRow(newMedia));
1,392✔
628
        }
1,392✔
629
      }
61✔
630
    });
68✔
631

68✔
632
    await Promise.all(promises);
68✔
633

68✔
634
    for (const item of items) {
68✔
635
      if (!allMedias.has(`${item.sourceType}:${item.sourceID}`)) {
1,401!
UNCOV
636
        throw new MediaNotFoundError({ sourceType: item.sourceType, sourceID: item.sourceID });
×
637
      }
×
638
    }
1,401✔
639

68✔
640
    return allMedias;
68✔
641
  }
68✔
642

180✔
643
  /**
180✔
644
   * Add items to a playlist.
180✔
645
   *
180✔
646
   * @param {Playlist} playlist
180✔
647
   * @param {PlaylistItemDesc[]} items
180✔
648
   * @param {{ after: PlaylistItemID } | { at: 'start' | 'end' }} [options]
180✔
649
   */
180✔
650
  async addPlaylistItems(playlist, items, options = { at: 'end' }) {
180✔
651
    const { users } = this.#uw;
68✔
652
    const user = await users.getUser(playlist.userID);
68✔
653
    if (!user) {
68!
UNCOV
654
      throw new UserNotFoundError({ id: playlist.userID });
×
655
    }
×
656

68✔
657
    const medias = await this.resolveMedia(user, items);
68✔
658
    const playlistItems = items.map((item) => {
68✔
659
      const media = medias.get(`${item.sourceType}:${item.sourceID}`);
1,401✔
660
      if (media == null) {
1,401!
UNCOV
661
        throw new Error('resolveMedia() should have errored');
×
662
      }
×
663
      const { start, end } = getStartEnd(item, media);
1,401✔
664
      return {
1,401✔
665
        id: /** @type {PlaylistItemID} */ (randomUUID()),
1,401✔
666
        media: media,
1,401✔
667
        artist: item.artist ?? media.artist,
1,401✔
668
        title: item.title ?? media.title,
1,401✔
669
        start,
1,401✔
670
        end,
1,401✔
671
      };
1,401✔
672
    });
68✔
673

68✔
674
    const result = await this.#uw.db.transaction().execute(async (tx) => {
68✔
675
      for (const item of playlistItems) {
68✔
676
        // TODO: use a prepared statement
1,401✔
677
        await tx.insertInto('playlistItems')
1,401✔
678
          .values({
1,401✔
679
            id: item.id,
1,401✔
680
            playlistID: playlist.id,
1,401✔
681
            mediaID: item.media.id,
1,401✔
682
            artist: item.artist,
1,401✔
683
            title: item.title,
1,401✔
684
            start: item.start,
1,401✔
685
            end: item.end,
1,401✔
686
          })
1,401✔
687
          .execute();
1,401✔
688
      }
1,401✔
689

68✔
690
      const result = await tx.selectFrom('playlists')
68✔
691
        .select((eb) => json(eb.ref('items')).as('items'))
68✔
692
        .where('id', '=', playlist.id)
68✔
693
        .executeTakeFirstOrThrow();
68✔
694

68✔
695
      const oldItems = result?.items ? fromJson(result.items) : [];
68!
696

68✔
697
      /** @type {PlaylistItemID | null} */
68✔
698
      let after;
68✔
699
      let newItems;
68✔
700
      if ('after' in options) {
68✔
701
        after = options.after;
1✔
702
        const insertIndex = oldItems.indexOf(options.after);
1✔
703
        newItems = [
1✔
704
          ...oldItems.slice(0, insertIndex + 1),
1✔
705
          ...playlistItems.map((item) => item.id),
1✔
706
          ...oldItems.slice(insertIndex + 1),
1✔
707
        ];
1✔
708
      } else if (options.at === 'start') {
68✔
709
        after = null;
18✔
710
        newItems = playlistItems.map((item) => item.id).concat(oldItems);
18✔
711
      } else {
67✔
712
        newItems = oldItems.concat(playlistItems.map((item) => item.id));
49✔
713
        after = oldItems.at(-1) ?? null;
49✔
714
      }
49✔
715

68✔
716
      await tx.updateTable('playlists')
68✔
717
        .where('id', '=', playlist.id)
68✔
718
        .set({ items: jsonb(newItems) })
68✔
719
        .executeTakeFirstOrThrow();
68✔
720

68✔
721
      return {
68✔
722
        added: playlistItems,
68✔
723
        afterID: after,
68✔
724
        playlistSize: newItems.length,
68✔
725
      };
68✔
726
    });
68✔
727

68✔
728
    return result;
68✔
729
  }
68✔
730

180✔
731
  /**
180✔
732
   * @param {Omit<PlaylistItem, 'playlistID'>} item
180✔
733
   * @param {Partial<Pick<PlaylistItem, 'artist' | 'title' | 'start' | 'end'>>} patch
180✔
734
   * @returns {Promise<PlaylistItem>}
180✔
735
   */
180✔
736
  async updatePlaylistItem(item, patch = {}, tx = this.#uw.db) {
180✔
UNCOV
737
    const updatedItem = await tx.updateTable('playlistItems')
×
738
      .where('id', '=', item.id)
×
739
      .set(patch)
×
740
      .returningAll()
×
741
      .executeTakeFirstOrThrow();
×
742

×
743
    return updatedItem;
×
744
  }
×
745

180✔
746
  /**
180✔
747
   * @param {Playlist} playlist
180✔
748
   * @param {PlaylistItemID[]} itemIDs
180✔
749
   * @param {{ after: PlaylistItemID } | { at: 'start' | 'end' }} options
180✔
750
   */
180✔
751
  async movePlaylistItems(playlist, itemIDs, options) {
180✔
752
    const { db } = this.#uw;
7✔
753

7✔
754
    await db.transaction().execute(async (tx) => {
7✔
755
      const result = await tx.selectFrom('playlists')
7✔
756
        .select((eb) => json(eb.ref('items')).as('items'))
7✔
757
        .where('id', '=', playlist.id)
7✔
758
        .executeTakeFirst();
7✔
759

7✔
760
      const items = result?.items ? fromJson(result.items) : [];
7!
761
      const itemIDsInPlaylist = new Set(items);
7✔
762
      const itemIDsToMove = new Set(itemIDs.filter((itemID) => itemIDsInPlaylist.has(itemID)));
7✔
763

7✔
764
      /** @type {PlaylistItemID[]} */
7✔
765
      let newItemIDs = [];
7✔
766
      /** Index in the new item array to move the item IDs to. */
7✔
767
      let insertIndex = 0;
7✔
768
      let index = 0;
7✔
769
      for (const itemID of itemIDsInPlaylist) {
7✔
770
        if (!itemIDsToMove.has(itemID)) {
140✔
771
          index += 1;
129✔
772
          newItemIDs.push(itemID);
129✔
773
        }
129✔
774
        if ('after' in options && itemID === options.after) {
140!
UNCOV
775
          insertIndex = index;
×
776
        }
×
777
      }
140✔
778

7✔
779
      if ('after' in options) {
7!
UNCOV
780
        newItemIDs = [
×
781
          ...newItemIDs.slice(0, insertIndex + 1),
×
782
          ...itemIDsToMove,
×
783
          ...newItemIDs.slice(insertIndex + 1),
×
784
        ];
×
785
      } else if (options.at === 'start') {
7✔
786
        newItemIDs = [...itemIDsToMove, ...newItemIDs];
5✔
787
      } else {
7✔
788
        newItemIDs = [...newItemIDs, ...itemIDsToMove];
2✔
789
      }
2✔
790

7✔
791
      await tx.updateTable('playlists')
7✔
792
        .where('id', '=', playlist.id)
7✔
793
        .set('items', jsonb(newItemIDs))
7✔
794
        .execute();
7✔
795
    });
7✔
796

7✔
797
    return {};
7✔
798
  }
7✔
799

180✔
800
  /**
180✔
801
   * @param {Playlist} playlist
180✔
802
   * @param {PlaylistItemID[]} itemIDs
180✔
803
   */
180✔
804
  async removePlaylistItems(playlist, itemIDs) {
180✔
805
    const { db } = this.#uw;
3✔
806

3✔
807
    await db.transaction().execute(async (tx) => {
3✔
808
      const rows = await tx.selectFrom('playlists')
3✔
809
        .innerJoin((eb) => jsonEach(eb.ref('playlists.items')).as('playlistItemIDs'), (join) => join)
3✔
810
        .select('playlistItemIDs.value as itemID')
3✔
811
        .where('playlists.id', '=', playlist.id)
3✔
812
        .execute();
3✔
813

3✔
814
      // Only remove items that are actually in this playlist.
3✔
815
      const set = new Set(itemIDs);
3✔
816
      /** @type {PlaylistItemID[]} */
3✔
817
      const toRemove = [];
3✔
818
      /** @type {PlaylistItemID[]} */
3✔
819
      const toKeep = [];
3✔
820
      rows.forEach(({ itemID }) => {
3✔
821
        if (set.has(itemID)) {
60✔
822
          toRemove.push(itemID);
11✔
823
        } else {
60✔
824
          toKeep.push(itemID);
49✔
825
        }
49✔
826
      });
3✔
827

3✔
828
      await tx.updateTable('playlists')
3✔
829
        .where('id', '=', playlist.id)
3✔
830
        .set({ items: jsonb(toKeep) })
3✔
831
        .execute();
3✔
832
      await tx.deleteFrom('playlistItems')
3✔
833
        .where('id', 'in', toRemove)
3✔
834
        .execute();
3✔
835
    });
3✔
836
  }
3✔
837
}
180✔
838

1✔
839
/**
1✔
840
 * @param {import('../Uwave.js').default} uw
1✔
841
 */
1✔
842
async function playlistsPlugin(uw) {
180✔
843
  uw.playlists = new PlaylistsRepository(uw);
180✔
844
  uw.httpApi.use('/playlists', routes());
180✔
845
}
180✔
846

1✔
847
export default playlistsPlugin;
1✔
848
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