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

u-wave / core / 23241410419

18 Mar 2026 10:59AM UTC coverage: 71.344% (-16.0%) from 87.348%
23241410419

Pull #756

github

web-flow
Merge 1d9eda950 into acce7f2f9
Pull Request #756: Switch tests to use vitest

697 of 1141 branches covered (61.09%)

Branch coverage included in aggregate %.

2064 of 2729 relevant lines covered (75.63%)

180.42 hits per line

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

85.36
/src/plugins/playlists.js
1
import ObjectGroupBy from 'object.groupby';
2
import {
3
  PlaylistNotFoundError,
4
  ItemNotInPlaylistError,
5
  MediaNotFoundError,
6
  UserNotFoundError,
7
  PlaylistActiveError,
8
} from '../errors/index.js';
9
import Page from '../Page.js';
10
import routes from '../routes/playlists.js';
11
import { randomUUID } from 'node:crypto';
12
import { sql } from 'kysely';
13
import {
14
  arrayCycle,
15
  arrayShuffle as arrayShuffle,
16
  fromJson,
17
  isForeignKeyError,
18
  json,
19
  jsonb,
20
  jsonEach,
21
  jsonLength,
22
} from '../utils/sqlite.js';
23
import Multimap from '../utils/Multimap.js';
24

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

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

47
/**
48
 * Calculate valid start/end times for a playlist item.
49
 *
50
 * @param {PlaylistItemDesc} item
51
 * @param {Pick<Media, 'duration'>} media
52
 */
53
function getStartEnd(item, media) {
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) {
×
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!
63
    end = start;
×
64
  }
65
  return { start, end };
1,401✔
66
}
67

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

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

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

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

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

207
class PlaylistsRepository {
208
  #uw;
209

210
  #logger;
211

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

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

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

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

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

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

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

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

289
    return result;
77✔
290
  }
291

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

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

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

331
    return updatedPlaylist;
2✔
332
  }
333

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

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

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

371
    try {
2✔
372
      // Missing `ON DELETE CASCADE`, so we have to do it manually, unfortunately...
373
      await tx.deleteFrom('playlistItems')
2✔
374
        .where('playlistID', '=', playlist.id)
375
        .execute();
376
      await tx.deleteFrom('playlists')
2✔
377
        .where('id', '=', playlist.id)
378
        .execute();
379
    } catch (err) {
380
      if (isForeignKeyError(err)) {
1!
381
        throw new PlaylistActiveError();
1✔
382
      }
383
      throw err;
×
384
    }
385
  }
386

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

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

403
    return playlistItemFromSelectionNew(raw);
×
404
  }
405

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

571
    return playlistsByMediaID;
2✔
572
  }
573

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

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

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

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

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

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

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

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

641
    return allMedias;
68✔
642
  }
643

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

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

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

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

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

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

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

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

729
    return result;
68✔
730
  }
731

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

744
    return updatedItem;
×
745
  }
746

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

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

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

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

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

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

798
    return {};
7✔
799
  }
800

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

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

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

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

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

848
export default playlistsPlugin;
849
export { PlaylistsRepository };
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