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

u-wave / core / 11980470338

22 Nov 2024 09:32PM UTC coverage: 78.436% (-1.7%) from 80.16%
11980470338

Pull #637

github

goto-bus-stop
explicitly store UTC in sqlite
Pull Request #637: Switch to a relational database

757 of 915 branches covered (82.73%)

Branch coverage included in aggregate %.

1977 of 2768 new or added lines in 52 files covered. (71.42%)

9 existing lines in 7 files now uncovered.

8653 of 11082 relevant lines covered (78.08%)

70.79 hits per line

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

87.15
/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,341✔
45
  let { start, end } = item;
1,341✔
46
  if (!start || start < 0) {
1,341!
47
    start = 0;
1,341✔
48
  } else if (start > media.duration) {
1,341!
49
    start = media.duration;
×
50
  }
×
51
  if (!end || end > media.duration) {
1,341!
52
    end = media.duration;
1,341✔
53
  } else if (end < start) {
1,341!
54
    end = start;
×
55
  }
×
56
  return { start, end };
1,341✔
57
}
1,341✔
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) {
6✔
134
  return {
6✔
135
    playlistItem: {
6✔
136
      id: raw.id,
6✔
137
      mediaID: raw['media.id'],
6✔
138
      artist: raw.artist,
6✔
139
      title: raw.title,
6✔
140
      start: raw.start,
6✔
141
      end: raw.end,
6✔
142
      createdAt: raw.createdAt,
6✔
143
      updatedAt: raw.updatedAt,
6✔
144
    },
6✔
145
    media: {
6✔
146
      id: raw['media.id'],
6✔
147
      artist: raw['media.artist'],
6✔
148
      title: raw['media.title'],
6✔
149
      duration: raw['media.duration'],
6✔
150
      thumbnail: raw['media.thumbnail'],
6✔
151
      sourceID: raw['media.sourceID'],
6✔
152
      sourceType: raw['media.sourceType'],
6✔
153
      sourceData: raw['media.sourceData'],
6✔
154
    },
6✔
155
  };
6✔
156
}
6✔
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, tx = this.#uw.db) {
92✔
176
    const playlist = await tx.selectFrom('playlists')
58✔
177
      .where('userID', '=', user.id)
58✔
178
      .where('id', '=', id)
58✔
179
      .select([
58✔
180
        'id',
58✔
181
        'userID',
58✔
182
        'name',
58✔
183
        'createdAt',
58✔
184
        'updatedAt',
58✔
185
        (eb) => jsonLength(eb.ref('items')).as('size'),
58✔
186
      ])
58✔
187
      .executeTakeFirst();
58✔
188

58✔
189
    if (!playlist) {
58✔
190
      throw new PlaylistNotFoundError({ id });
6✔
191
    }
6✔
192
    return {
52✔
193
      ...playlist,
52✔
194
      size: Number(playlist.size),
52✔
195
    };
52✔
196
  }
58✔
197

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

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

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

43✔
233
    return { playlist, active };
43✔
234
  }
43✔
235

92✔
236
  /**
92✔
237
   * @param {User} user
92✔
238
   */
92✔
239
  async getUserPlaylists(user, tx = this.#uw.db) {
92✔
240
    const playlists = await tx.selectFrom('playlists')
3✔
241
      .where('userID', '=', user.id)
3✔
242
      .select([
3✔
243
        'id',
3✔
244
        'userID',
3✔
245
        'name',
3✔
246
        (eb) => jsonLength(eb.ref('items')).as('size'),
3✔
247
        'createdAt',
3✔
248
        'updatedAt',
3✔
249
      ])
3✔
250
      .execute();
3✔
251

3✔
252
    return playlists.map((playlist) => {
3✔
253
      return { ...playlist, size: Number(playlist.size) };
3✔
254
    });
3✔
255
  }
3✔
256

92✔
257
  /**
92✔
258
   * @param {Playlist} playlist
92✔
259
   * @param {Partial<Pick<Playlist, 'name'>>} patch
92✔
260
   */
92✔
261
  async updatePlaylist(playlist, patch = {}, tx = this.#uw.db) {
92✔
262
    const updatedPlaylist = await tx.updateTable('playlists')
2✔
263
      .where('id', '=', playlist.id)
2✔
264
      .set(patch)
2✔
265
      .returning([
2✔
266
        'id',
2✔
267
        'userID',
2✔
268
        'name',
2✔
269
        (eb) => jsonLength(eb.ref('items')).as('size'),
2✔
270
        'createdAt',
2✔
271
        'updatedAt',
2✔
272
      ])
2✔
273
      .executeTakeFirstOrThrow();
2✔
274

2✔
275
    return updatedPlaylist;
2✔
276
  }
2✔
277

92✔
278
  /**
92✔
279
   * "Cycle" the playlist, moving its first item to last.
92✔
280
   *
92✔
281
   * @param {Playlist} playlist
92✔
282
   */
92✔
283
  async cyclePlaylist(playlist, tx = this.#uw.db) {
92✔
284
    await tx.updateTable('playlists')
6✔
285
      .where('id', '=', playlist.id)
6✔
286
      .set('items', (eb) => arrayCycle(eb.ref('items')))
6✔
287
      .execute();
6✔
288
  }
6✔
289

92✔
290
  /**
92✔
291
   * @param {Playlist} playlist
92✔
292
   */
92✔
293
  async shufflePlaylist(playlist, tx = this.#uw.db) {
92✔
294
    await tx.updateTable('playlists')
1✔
295
      .where('id', '=', playlist.id)
1✔
296
      .set('items', (eb) => arrayShuffle(eb.ref('items')))
1✔
297
      .execute();
1✔
298
  }
1✔
299

92✔
300
  /**
92✔
301
   * @param {Playlist} playlist
92✔
302
   */
92✔
303
  async deletePlaylist(playlist, tx = this.#uw.db) {
92✔
NEW
304
    await tx.deleteFrom('playlists')
×
NEW
305
      .where('id', '=', playlist.id)
×
NEW
306
      .execute();
×
NEW
307
  }
×
308

92✔
309
  /**
92✔
310
   * @param {Playlist} playlist
92✔
311
   * @param {PlaylistItemID} itemID
92✔
312
   */
92✔
313
  async getPlaylistItem(playlist, itemID, tx = this.#uw.db) {
92✔
NEW
314
    const raw = await tx.selectFrom('playlistItems')
×
NEW
315
      .where('playlistItems.id', '=', itemID)
×
NEW
316
      .where('playlistItems.playlistID', '=', playlist.id)
×
NEW
317
      .innerJoin('media', 'media.id', 'playlistItems.mediaID')
×
NEW
318
      .select(playlistItemSelection)
×
NEW
319
      .executeTakeFirst();
×
NEW
320

×
NEW
321
    if (raw == null) {
×
NEW
322
      throw new ItemNotInPlaylistError({ playlistID: playlist.id, itemID });
×
323
    }
×
324

×
NEW
325
    return playlistItemFromSelectionNew(raw);
×
NEW
326
  }
×
327

92✔
328
  /**
92✔
329
   * @param {Playlist} playlist
92✔
330
   * @param {number} order
92✔
331
   */
92✔
332
  async getPlaylistItemAt(playlist, order, tx = this.#uw.db) {
92✔
333
    const raw = await tx.selectFrom('playlistItems')
6✔
334
      .where('playlistItems.playlistID', '=', playlist.id)
6✔
335
      .where('playlistItems.id', '=', (eb) => {
6✔
336
        /** @type {import('kysely').RawBuilder<PlaylistItemID>} */
6✔
337
        // items->>order doesn't work for some reason, not sure why
6✔
338
        const item =  sql`json_extract(items, ${`$[${order}]`})`;
6✔
339
        return eb.selectFrom('playlists')
6✔
340
          .select(item.as('playlistItemID'))
6✔
341
          .where('id', '=', playlist.id);
6✔
342
      })
6✔
343
      .innerJoin('media', 'media.id', 'playlistItems.mediaID')
6✔
344
      .select(playlistItemSelection)
6✔
345
      .executeTakeFirst();
6✔
346

6✔
347
    if (raw == null) {
6!
NEW
348
      throw new ItemNotInPlaylistError({ playlistID: playlist.id });
×
349
    }
×
350

6✔
351
    return playlistItemFromSelectionNew(raw);
6✔
352
  }
6✔
353

92✔
354
  /**
92✔
355
   * @param {{ id: PlaylistID }} playlist
92✔
356
   * @param {string|undefined} filter
92✔
357
   * @param {{ offset: number, limit: number }} pagination
92✔
358
   */
92✔
359
  async getPlaylistItems(playlist, filter, pagination, tx = this.#uw.db) {
92✔
360
    let query = tx.selectFrom('playlists')
11✔
361
      .innerJoin(
11✔
362
        (eb) => jsonEach(eb.ref('playlists.items')).as('playlistItemIDs'),
11✔
363
        (join) => join,
11✔
364
      )
11✔
365
      .innerJoin('playlistItems', (join) => join.on((eb) => eb(
11✔
366
        eb.ref('playlistItemIDs.value'),
11✔
367
        '=',
11✔
368
        eb.ref('playlistItems.id'),
11✔
369
      )))
11✔
370
      .innerJoin('media', 'playlistItems.mediaID', 'media.id')
11✔
371
      .where('playlists.id', '=', playlist.id)
11✔
372
      .select(playlistItemSelection);
11✔
373
    if (filter != null) {
11✔
374
      query = query.where((eb) => eb.or([
2✔
375
        eb('playlistItems.artist', 'like', `%${filter}%`),
2✔
376
        eb('playlistItems.title', 'like', `%${filter}%`),
2✔
377
      ]));
2✔
378
    }
2✔
379

11✔
380
    query = query
11✔
381
      .offset(pagination.offset)
11✔
382
      .limit(pagination.limit);
11✔
383

11✔
384
    const totalQuery = tx.selectFrom('playlists')
11✔
385
      .select((eb) => jsonLength(eb.ref('items')).as('count'))
11✔
386
      .where('id', '=', playlist.id)
11✔
387
      .executeTakeFirstOrThrow();
11✔
388

11✔
389
    const filteredQuery = filter == null ? totalQuery : tx.selectFrom('playlistItems')
11✔
390
      .select((eb) => eb.fn.countAll().as('count'))
2✔
391
      .where('playlistID', '=', playlist.id)
2✔
392
      .where((eb) => eb.or([
2✔
393
        eb('playlistItems.artist', 'like', `%${filter}%`),
2✔
394
        eb('playlistItems.title', 'like', `%${filter}%`),
2✔
395
      ]))
2✔
396
      .executeTakeFirstOrThrow();
11✔
397

11✔
398
    const [
11✔
399
      playlistItemsRaw,
11✔
400
      filtered,
11✔
401
      total,
11✔
402
    ] = await Promise.all([
11✔
403
      query.execute(),
11✔
404
      filteredQuery,
11✔
405
      totalQuery,
11✔
406
    ]);
11✔
407

11✔
408
    const playlistItems = playlistItemsRaw.map(playlistItemFromSelection);
11✔
409

11✔
410
    return new Page(playlistItems, {
11✔
411
      pageSize: pagination.limit,
11✔
412
      filtered: Number(filtered.count),
11✔
413
      total: Number(total.count),
11✔
414

11✔
415
      current: pagination,
11✔
416
      next: {
11✔
417
        offset: pagination.offset + pagination.limit,
11✔
418
        limit: pagination.limit,
11✔
419
      },
11✔
420
      previous: {
11✔
421
        offset: Math.max(pagination.offset - pagination.limit, 0),
11✔
422
        limit: pagination.limit,
11✔
423
      },
11✔
424
    });
11✔
425
  }
11✔
426

92✔
427
  /**
92✔
428
   * Get playlists containing a particular Media.
92✔
429
   *
92✔
430
   * @typedef {object} GetPlaylistsContainingMediaOptions
92✔
431
   * @prop {UserID} [author]
92✔
432
   * @prop {string[]} [fields]
92✔
433
   * @param {MediaID} mediaID
92✔
434
   * @param {GetPlaylistsContainingMediaOptions} options
92✔
435
   */
92✔
436
  async getPlaylistsContainingMedia(mediaID, options = {}, tx = this.#uw.db) {
92✔
NEW
437
    let query = tx.selectFrom('playlists')
×
NEW
438
      .select([
×
NEW
439
        'playlists.id',
×
NEW
440
        'playlists.userID',
×
NEW
441
        'playlists.name',
×
NEW
442
        (eb) => jsonLength(eb.ref('playlists.items')).as('size'),
×
NEW
443
        'playlists.createdAt',
×
NEW
444
        'playlists.updatedAt',
×
NEW
445
      ])
×
NEW
446
      .innerJoin('playlistItems', 'playlists.id', 'playlistItems.playlistID')
×
NEW
447
      .where('playlistItems.mediaID', '=', mediaID)
×
NEW
448
      .groupBy('playlistItems.playlistID');
×
449
    if (options.author) {
×
NEW
450
      query = query.where('playlists.userID', '=', options.author);
×
451
    }
×
452

×
NEW
453
    const playlists = await query.execute();
×
NEW
454
    return playlists;
×
UNCOV
455
  }
×
456

92✔
457
  /**
92✔
458
   * Get playlists that contain any of the given medias. If multiple medias are in a single
92✔
459
   * playlist, that playlist will be returned multiple times, keyed on the media's unique ObjectId.
92✔
460
   *
92✔
461
   * @param {MediaID[]} mediaIDs
92✔
462
   * @param {{ author?: UserID }} options
92✔
463
   * @returns A map of media IDs to the Playlist objects that contain them.
92✔
464
   */
92✔
465
  async getPlaylistsContainingAnyMedia(mediaIDs, options = {}, tx = this.#uw.db) {
92✔
NEW
466
    /** @type {Multimap<MediaID, Playlist>} */
×
NEW
467
    const playlistsByMediaID = new Multimap();
×
NEW
468
    if (mediaIDs.length === 0) {
×
NEW
469
      return playlistsByMediaID;
×
NEW
470
    }
×
471

×
NEW
472
    let query = tx.selectFrom('playlists')
×
NEW
473
      .innerJoin('playlistItems', 'playlists.id', 'playlistItems.playlistID')
×
NEW
474
      .select([
×
NEW
475
        'playlists.id',
×
NEW
476
        'playlists.userID',
×
NEW
477
        'playlists.name',
×
NEW
478
        (eb) => jsonLength(eb.ref('playlists.items')).as('size'),
×
NEW
479
        'playlists.createdAt',
×
NEW
480
        'playlists.updatedAt',
×
NEW
481
        'playlistItems.mediaID',
×
NEW
482
      ])
×
NEW
483
      .where('playlistItems.mediaID', 'in', mediaIDs);
×
484
    if (options.author) {
×
NEW
485
      query = query.where('playlists.userID', '=', options.author);
×
486
    }
×
487

×
NEW
488
    const playlists = await query.execute();
×
NEW
489
    for (const { mediaID, ...playlist } of playlists) {
×
NEW
490
      playlistsByMediaID.set(mediaID, playlist);
×
NEW
491
    }
×
492

×
493
    return playlistsByMediaID;
×
494
  }
×
495

92✔
496
  /**
92✔
497
   * Load media for all the given source type/source IDs.
92✔
498
   *
92✔
499
   * @param {User} user
92✔
500
   * @param {{ sourceType: string, sourceID: string }[]} items
92✔
501
   */
92✔
502
  async resolveMedia(user, items) {
92✔
503
    const { db } = this.#uw;
36✔
504

36✔
505
    // Group by source so we can retrieve all unknown medias from the source in
36✔
506
    // one call.
36✔
507
    const itemsBySourceType = ObjectGroupBy(items, (item) => item.sourceType);
36✔
508
    /** @type {Map<string, Media>} */
36✔
509
    const allMedias = new Map();
36✔
510
    const promises = Object.entries(itemsBySourceType).map(async ([sourceType, sourceItems]) => {
36✔
511
      const knownMedias = await db.selectFrom('media')
32✔
512
        .where('sourceType', '=', sourceType)
32✔
513
        .where('sourceID', 'in', sourceItems.map((item) => String(item.sourceID)))
32✔
514
        .selectAll()
32✔
515
        .execute();
32✔
516

32✔
517
      /** @type {Set<string>} */
32✔
518
      const knownMediaIDs = new Set();
32✔
519
      knownMedias.forEach((knownMedia) => {
32✔
NEW
520
        allMedias.set(`${knownMedia.sourceType}:${knownMedia.sourceID}`, knownMedia);
×
UNCOV
521
        knownMediaIDs.add(knownMedia.sourceID);
×
522
      });
32✔
523

32✔
524
      /** @type {string[]} */
32✔
525
      const unknownMediaIDs = [];
32✔
526
      sourceItems.forEach((item) => {
32✔
527
        if (!knownMediaIDs.has(String(item.sourceID))) {
1,341✔
528
          unknownMediaIDs.push(String(item.sourceID));
1,341✔
529
        }
1,341✔
530
      });
32✔
531

32✔
532
      if (unknownMediaIDs.length > 0) {
32✔
533
        // @ts-expect-error TS2322
32✔
534
        const unknownMedias = await this.#uw.source(sourceType).get(user, unknownMediaIDs);
32✔
535
        for (const media of unknownMedias) {
32✔
536
          const newMedia = await db.insertInto('media')
1,341✔
537
            .values({
1,341✔
538
              id: /** @type {MediaID} */ (randomUUID()),
1,341✔
539
              sourceType: media.sourceType,
1,341✔
540
              sourceID: media.sourceID,
1,341✔
541
              sourceData: jsonb(media.sourceData),
1,341✔
542
              artist: media.artist,
1,341✔
543
              title: media.title,
1,341✔
544
              duration: media.duration,
1,341✔
545
              thumbnail: media.thumbnail,
1,341✔
546
            })
1,341✔
547
            .returningAll()
1,341✔
548
            .executeTakeFirstOrThrow();
1,341✔
549

1,341✔
550
          allMedias.set(`${media.sourceType}:${media.sourceID}`, newMedia);
1,341✔
551
        }
1,341✔
552
      }
32✔
553
    });
36✔
554

36✔
555
    await Promise.all(promises);
36✔
556

36✔
557
    for (const item of items) {
36✔
558
      if (!allMedias.has(`${item.sourceType}:${item.sourceID}`)) {
1,341!
NEW
559
        throw new MediaNotFoundError({ sourceType: item.sourceType, sourceID: item.sourceID });
×
NEW
560
      }
×
561
    }
1,341✔
562

36✔
563
    return allMedias;
36✔
564
  }
36✔
565

92✔
566
  /**
92✔
567
   * Add items to a playlist.
92✔
568
   *
92✔
569
   * @param {Playlist} playlist
92✔
570
   * @param {PlaylistItemDesc[]} items
92✔
571
   * @param {{ after: PlaylistItemID } | { at: 'start' | 'end' }} [options]
92✔
572
   */
92✔
573
  async addPlaylistItems(playlist, items, options = { at: 'end' }) {
92✔
574
    const { users } = this.#uw;
36✔
575
    const user = await users.getUser(playlist.userID);
36✔
576
    if (!user) {
36!
NEW
577
      throw new UserNotFoundError({ id: playlist.userID });
×
578
    }
×
579

36✔
580
    const medias = await this.resolveMedia(user, items);
36✔
581
    const playlistItems = items.map((item) => {
36✔
582
      const media = medias.get(`${item.sourceType}:${item.sourceID}`);
1,341✔
583
      if (media == null) {
1,341!
NEW
584
        throw new Error('resolveMedia() should have errored');
×
NEW
585
      }
×
586
      const { start, end } = getStartEnd(item, media);
1,341✔
587
      return {
1,341✔
588
        id: /** @type {PlaylistItemID} */ (randomUUID()),
1,341✔
589
        media: media,
1,341✔
590
        artist: item.artist ?? media.artist,
1,341✔
591
        title: item.title ?? media.title,
1,341✔
592
        start,
1,341✔
593
        end,
1,341✔
594
      };
1,341✔
595
    });
36✔
596

36✔
597
    const result = await this.#uw.db.transaction().execute(async (tx) => {
36✔
598
      for (const item of playlistItems) {
36✔
599
        // TODO: use a prepared statement
1,341✔
600
        await tx.insertInto('playlistItems')
1,341✔
601
          .values({
1,341✔
602
            id: item.id,
1,341✔
603
            playlistID: playlist.id,
1,341✔
604
            mediaID: item.media.id,
1,341✔
605
            artist: item.artist,
1,341✔
606
            title: item.title,
1,341✔
607
            start: item.start,
1,341✔
608
            end: item.end,
1,341✔
609
          })
1,341✔
610
          .execute();
1,341✔
611
      }
1,341✔
612

36✔
613
      const result = await tx.selectFrom('playlists')
36✔
614
        .select(sql`json(items)`.as('items'))
36✔
615
        .where('id', '=', playlist.id)
36✔
616
        .executeTakeFirstOrThrow();
36✔
617

36✔
618
      /** @type {PlaylistItemID[]} */
36✔
619
      const oldItems = result?.items ? JSON.parse(/** @type {string} */ (result.items)) : [];
36!
620

36✔
621
      /** @type {PlaylistItemID | null} */
36✔
622
      let after;
36✔
623
      let newItems;
36✔
624
      if ('after' in options) {
36✔
625
        after = options.after;
1✔
626
        const insertIndex = oldItems.indexOf(options.after);
1✔
627
        newItems = [
1✔
628
          ...oldItems.slice(0, insertIndex + 1),
1✔
629
          ...playlistItems.map((item) => item.id),
1✔
630
          ...oldItems.slice(insertIndex + 1),
1✔
631
        ];
1✔
632
      } else if (options.at === 'start') {
36✔
633
        after = null;
18✔
634
        newItems = playlistItems.map((item) => item.id).concat(oldItems);
18✔
635
      } else {
35✔
636
        newItems = oldItems.concat(playlistItems.map((item) => item.id));
17✔
637
        after = oldItems.at(-1) ?? null;
17✔
638
      }
17✔
639

36✔
640
      await tx.updateTable('playlists')
36✔
641
        .where('id', '=', playlist.id)
36✔
642
        .set({ items: jsonb(newItems) })
36✔
643
        .executeTakeFirstOrThrow();
36✔
644

36✔
645
      return {
36✔
646
        added: playlistItems,
36✔
647
        afterID: after,
36✔
648
        playlistSize: newItems.length,
36✔
649
      };
36✔
650
    });
36✔
651

36✔
652
    return result;
36✔
653
  }
36✔
654

92✔
655
  /**
92✔
656
   * @param {Omit<PlaylistItem, 'playlistID'>} item
92✔
657
   * @param {Partial<Pick<PlaylistItem, 'artist' | 'title' | 'start' | 'end'>>} patch
92✔
658
   * @returns {Promise<PlaylistItem>}
92✔
659
   */
92✔
660
  async updatePlaylistItem(item, patch = {}, tx = this.#uw.db) {
92✔
NEW
661
    const { db } = this.#uw;
×
NEW
662

×
NEW
663
    const updatedItem = await tx.updateTable('playlistItems')
×
NEW
664
      .where('id', '=', item.id)
×
NEW
665
      .set(patch)
×
NEW
666
      .returningAll()
×
NEW
667
      .executeTakeFirstOrThrow();
×
UNCOV
668

×
NEW
669
    return updatedItem;
×
670
  }
×
671

92✔
672
  /**
92✔
673
   * @param {Playlist} playlist
92✔
674
   * @param {PlaylistItemID[]} itemIDs
92✔
675
   * @param {{ after: PlaylistItemID } | { at: 'start' | 'end' }} options
92✔
676
   */
92✔
677
  async movePlaylistItems(playlist, itemIDs, options) {
92✔
678
    const { db } = this.#uw;
7✔
679

7✔
680
    await db.transaction().execute(async (tx) => {
7✔
681
      const result = await tx.selectFrom('playlists')
7✔
682
        .select(sql`json(items)`.as('items'))
7✔
683
        .where('id', '=', playlist.id)
7✔
684
        .executeTakeFirst();
7✔
685

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

7✔
690
      /** @type {PlaylistItemID[]} */
7✔
691
      let newItemIDs = [];
7✔
692
      /** Index in the new item array to move the item IDs to. */
7✔
693
      let insertIndex = 0;
7✔
694
      let index = 0;
7✔
695
      for (const itemID of itemIDsInPlaylist) {
7✔
696
        if (!itemIDsToMove.has(itemID)) {
140✔
697
          index += 1;
129✔
698
          newItemIDs.push(itemID);
129✔
699
        }
129✔
700
        if ('after' in options && itemID === options.after) {
140!
NEW
701
          insertIndex = index;
×
NEW
702
        }
×
703
      }
140✔
704

7✔
705
      let after;
7✔
706
      if ('after' in options) {
7!
NEW
707
        after = options.after;
×
NEW
708
        newItemIDs = [
×
NEW
709
          ...newItemIDs.slice(0, insertIndex + 1),
×
NEW
710
          ...itemIDsToMove,
×
NEW
711
          ...newItemIDs.slice(insertIndex + 1),
×
NEW
712
        ];
×
713
      } else if (options.at === 'start') {
7✔
714
        after = null;
5✔
715
        newItemIDs = [...itemIDsToMove, ...newItemIDs];
5✔
716
      } else {
7✔
717
        newItemIDs = [...newItemIDs, ...itemIDsToMove];
2✔
718
        after = newItemIDs.at(-1) ?? null;
2!
719
      }
2✔
720

7✔
721
      await tx.updateTable('playlists')
7✔
722
        .where('id', '=', playlist.id)
7✔
723
        .set('items', jsonb(newItemIDs))
7✔
724
        .execute();
7✔
725
    });
7✔
726

7✔
727
    return {};
7✔
728
  }
7✔
729

92✔
730
  /**
92✔
731
   * @param {Playlist} playlist
92✔
732
   * @param {PlaylistItemID[]} itemIDs
92✔
733
   */
92✔
734
  async removePlaylistItems(playlist, itemIDs) {
92✔
735
    const { db } = this.#uw;
3✔
736

3✔
737
    await db.transaction().execute(async (tx) => {
3✔
738
    const rows = await tx.selectFrom('playlists')
3✔
739
      .innerJoin((eb) => jsonEach(eb.ref('playlists.items')).as('playlistItemIDs'), (join) => join)
3✔
740
      .select('playlistItemIDs.value as itemID')
3✔
741
      .where('playlists.id', '=', playlist.id)
3✔
742
      .execute();
3✔
743

3✔
744
    // Only remove items that are actually in this playlist.
3✔
745
    const set = new Set(itemIDs);
3✔
746
    /** @type {PlaylistItemID[]} */
3✔
747
    const toRemove = [];
3✔
748
    /** @type {PlaylistItemID[]} */
3✔
749
    const toKeep = [];
3✔
750
    rows.forEach(({ itemID }) => {
3✔
751
      if (set.has(itemID)) {
60✔
752
        toRemove.push(itemID);
11✔
753
      } else {
60✔
754
        toKeep.push(itemID);
49✔
755
      }
49✔
756
    });
3✔
757

3✔
758
      await tx.updateTable('playlists')
3✔
759
        .where('id', '=', playlist.id)
3✔
760
        .set({ items: jsonb(toKeep) })
3✔
761
        .execute();
3✔
762
      await tx.deleteFrom('playlistItems')
3✔
763
        .where('id', 'in', toRemove)
3✔
764
        .execute();
3✔
765
    });
3✔
766
  }
3✔
767
}
92✔
768

1✔
769
/**
1✔
770
 * @param {import('../Uwave.js').default} uw
1✔
771
 */
1✔
772
async function playlistsPlugin(uw) {
92✔
773
  uw.playlists = new PlaylistsRepository(uw);
92✔
774
  uw.httpApi.use('/playlists', routes());
92✔
775
}
92✔
776

1✔
777
export default playlistsPlugin;
1✔
778
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