feat(curriculum): add lab playlist remix engine (#65515)

Co-authored-by: Dario <105294544+Dario-DC@users.noreply.github.com>
Co-authored-by: Ilenia M <nethleen@gmail.com>
Co-authored-by: majestic-owl448 <26656284+majestic-owl448@users.noreply.github.com>
Co-authored-by: Jessica Wilkins <67210629+jdwilkin4@users.noreply.github.com>
Co-authored-by: jdwilkin4 <jwilkin4@hotmail.com>
This commit is contained in:
VuBui217
2026-04-21 05:05:15 -04:00
committed by GitHub
parent 641585f419
commit 1cf34f9696
4 changed files with 399 additions and 0 deletions
@@ -0,0 +1,377 @@
---
id: 6976db7ecfa770bf21307b20
title: Build a Playlist Remix Engine
challengeType: 26
dashedName: build-a-playlist-remix-engine
---
# --description--
In this lab, you will build a program that creates a single remix playlist from multiple playlists submitted by listeners.
Each listener provides a list of songs they want to hear. Some songs may appear more than once, and some artists may show up too many times. Your job is to work through these playlists step by step: combine them into one list, score each song, remove duplicate songs, limit how often the same artist appears, and then create a final play order.
**Objective:** Fulfill the user stories below and get all the tests to pass to complete the lab.
**User Stories:**
1. You should create a function named `flattenPlaylists` that accepts an array of playlists where each playlist is an array of objects with the following properties: `trackId`, `artist`, `title`, `votes`, `bpm`. If the input is not an array, `flattenPlaylists` should return an empty array. An example playlist has been provided for you. You can use this example to test out your function.
2. `flattenPlaylists` should return a flat array of track objects, where each object includes all the original track properties plus a `source` property set to an array with the playlist index and the track index indicating where the track originated.
3. You should create a function named `scoreTracks` that accepts an array of track objects as returned by `flattenPlaylists` (each with `trackId`, `artist`, `title`, `votes`, `bpm`, and `source` properties) and returns a new array of track objects, each with a `score` property added using the formula: `votes * 10 - Math.abs(bpm - 120)`.
4. You should create a function named `dedupeTracks` that accepts an array of track objects as returned by `scoreTracks` and returns a new array with duplicate `trackId` entries removed, keeping only the first occurrence of each.
5. You should create a function named `enforceArtistQuota` that accepts an array of track objects as returned by `dedupeTracks` and a number representing the maximum allowed occurrences per artist. The function should return a new array where no artist appears more times than the given number, keeping the earliest occurrences.
6. You should create a function named `buildSchedule` that accepts an array of track objects as returned by `enforceArtistQuota` and returns a new array of `{ slot, trackId }` objects, where `slot` is a 1-based index representing each track's position in the broadcast order.
7. You should create a function named `remixPlaylist` that accepts an array of playlists and the maximum number of allowed occurrences per artist. The function should return the final broadcast schedule as an array of `{ slot, trackId }` objects, by calling `flattenPlaylists`, `scoreTracks`, `dedupeTracks`, `enforceArtistQuota`, and `buildSchedule` in order.
# --hints--
You should have a function named `flattenPlaylists`.
```js
assert.isFunction(flattenPlaylists);
```
You should return an empty array from `flattenPlaylists` when the input is not an array.
```js
assert.deepEqual(flattenPlaylists(null), []);
assert.deepEqual(flattenPlaylists(undefined), []);
assert.deepEqual(flattenPlaylists({}), []);
assert.deepEqual(flattenPlaylists("not an array"), []);
assert.deepEqual(flattenPlaylists(123), []);
```
Each track returned by `flattenPlaylists` should include a `source` field that is an array containing the playlist index and the track index.
```js
const playlists = [
[
{ trackId: "t1", artist: "A", title: "Song 1", votes: 2, bpm: 120 },
{ trackId: "t2", artist: "B", title: "Song 2", votes: 1, bpm: 110 }
],
[{ trackId: "t3", artist: "C", title: "Song 3", votes: 5, bpm: 130 }]
];
const flat = flattenPlaylists(playlists);
assert.isArray(flat);
assert.lengthOf(flat, 3);
flat.forEach((t) => {
assert.property(t, "source");
assert.isArray(t.source);
assert.lengthOf(t.source, 2);
assert.isNumber(t.source[0]);
assert.isNumber(t.source[1]);
});
assert.deepEqual(flat[0].source, [0, 0]);
assert.deepEqual(flat[1].source, [0, 1]);
assert.deepEqual(flat[2].source, [1, 0]);
```
You should have a function named `scoreTracks`.
```js
assert.isFunction(scoreTracks)
```
Each track returned by `scoreTracks` should include a numeric `score` field.
```js
const tracks = [
{ trackId: "t1", artist: "A", title: "Song 1", votes: 2, bpm: 120, source: [0, 0] }
];
const scored = scoreTracks(tracks);
assert.isArray(scored);
assert.lengthOf(scored, 1);
assert.property(scored[0], "score");
assert.isNumber(scored[0].score);
```
You should calculate `score` using a target BPM of `120` and this formula: `votes * 10 - Math.abs(bpm - 120)`.
```js
const tracks = [
{ trackId: "t1", artist: "A", title: "Song 1", votes: 3, bpm: 120, source: [0, 0] }, // 30
{ trackId: "t2", artist: "B", title: "Song 2", votes: 1, bpm: 100, source: [0, 1] }, // -10
{ trackId: "t3", artist: "C", title: "Song 3", votes: 2, bpm: 135, source: [1, 0] } // 5
];
const formulaScored = scoreTracks(tracks);
const s1 = formulaScored.find(t => t.trackId === "t1").score;
const s2 = formulaScored.find(t => t.trackId === "t2").score;
const s3 = formulaScored.find(t => t.trackId === "t3").score;
assert.equal(s1, 30);
assert.equal(s2, -10);
assert.equal(s3, 5);
```
You should have a function named `dedupeTracks`.
```js
assert.isFunction(dedupeTracks)
```
When duplicate `trackId` values exist, `dedupeTracks` should keep only the first occurrence of the track.
```js
const dupes = [
{ trackId: "t1", artist: "A", title: "Song 1", votes: 1, bpm: 120, source: [0, 0], score: 10 },
{ trackId: "t2", artist: "B", title: "Song 2", votes: 2, bpm: 119, source: [0, 1], score: 19 },
{ trackId: "t1", artist: "A", title: "Song 3", votes: 9, bpm: 140, source: [1, 0], score: 70 }
];
const deduped = dedupeTracks(dupes);
assert.isArray(deduped);
assert.lengthOf(deduped, 2);
assert.equal(deduped[0].trackId, "t1");
assert.equal(deduped[0].title, "Song 1");
assert.equal(deduped[1].trackId, "t2");
assert.equal(deduped[1].title, "Song 2");
```
You should have a function named `enforceArtistQuota`.
```js
assert.isFunction(enforceArtistQuota)
```
`enforceArtistQuota` should ensure no artist appears more than `maxPerArtist` times by removing extra tracks while keeping the earliest ones.
```js
const quotaTracks = [
{ trackId: "t1", artist: "A", title: "Song 1", votes: 1, bpm: 120, source: [0, 0], score: 10 },
{ trackId: "t2", artist: "A", title: "Song 2", votes: 1, bpm: 121, source: [0, 1], score: 9 },
{ trackId: "t3", artist: "B", title: "Song 3", votes: 1, bpm: 118, source: [0, 2], score: 8 },
{ trackId: "t4", artist: "A", title: "Song 4", votes: 1, bpm: 110, source: [1, 0], score: 0 }
];
const limited = enforceArtistQuota(quotaTracks, 2);
let artistACount = 0;
for (const track of limited) {
if (track.artist === "A") {
artistACount++;
}
}
assert.isAtMost(artistACount, 2);
assert.notInclude(limited.map(t => t.trackId), "t4");
```
You should have a function named `buildSchedule`.
```js
assert.isFunction(buildSchedule);
```
`buildSchedule` should return an array of objects with the shape `{ slot, trackId }`, where `slot` starts at `1`.
```js
const scheduleInput = [{ trackId: "t1" }, { trackId: "t2" }, { trackId: "t3" }];
const schedule = buildSchedule(scheduleInput);
assert.lengthOf(schedule, 3);
schedule.forEach((item, index) => {
assert.hasAllKeys(item, ["slot", "trackId"]);
assert.equal(item.slot, index + 1);
assert.equal(item.trackId, scheduleInput[index].trackId);
});
```
You should have a function named `remixPlaylist`.
```js
assert.isFunction(remixPlaylist)
```
`remixPlaylist` should call the helper functions in order to produce the final schedule.
```js
assert.lengthOf(remixPlaylist, 2);
const testPlaylists = [
[
{ trackId: "t1", artist: "A", title: "Song 1", votes: 2, bpm: 120 },
{ trackId: "t1", artist: "A", title: "Duplicate", votes: 9, bpm: 140 }
],
[
{ trackId: "t2", artist: "A", title: "Song 2", votes: 1, bpm: 110 },
{ trackId: "t3", artist: "B", title: "Song 3", votes: 4, bpm: 118 }
]
];
const expected = buildSchedule(enforceArtistQuota(dedupeTracks(scoreTracks(flattenPlaylists(testPlaylists))),1));
const actual = remixPlaylist(testPlaylists, 1);
assert.deepEqual(actual, expected);
```
# --seed--
## --seed-contents--
```js
const playlists = [
[
{
trackId: "trk101",
artist: "Velvet Comet",
title: "Crimson Afterglow",
votes: 5,
bpm: 122
},
{
trackId: "trk102",
artist: "Neon Harbor",
title: "Static Horizon",
votes: 2,
bpm: 108
},
{
trackId: "trk103",
artist: "Lunar Arcade",
title: "Midnight Frequency",
votes: 4,
bpm: 128
}
],
[
{
trackId: "trk201",
artist: "Solar Echo",
title: "Glass Skyline",
votes: 3,
bpm: 115
},
{
trackId: "trk202",
artist: "Velvet Comet",
title: "Satellite Hearts",
votes: 6,
bpm: 124
}
]
];
```
# --solutions--
```js
function flattenPlaylists(playlists) {
if (!Array.isArray(playlists)) return [];
const result = [];
for (let i = 0; i < playlists.length; i++) {
const playlist = playlists[i];
if (!Array.isArray(playlist)) continue;
for (let j = 0; j < playlist.length; j++) {
const track = playlist[j];
result.push({
trackId: track.trackId,
artist: track.artist,
title: track.title,
votes: track.votes,
bpm: track.bpm,
source: [i, j]
});
}
}
return result;
}
function scoreTracks(tracks) {
const targetBpm = 120;
const result = [];
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
result.push({
trackId: track.trackId,
artist: track.artist,
title: track.title,
votes: track.votes,
bpm: track.bpm,
source: track.source,
score: (track.votes * 10) - Math.abs(track.bpm - targetBpm)
});
}
return result;
}
function dedupeTracks(tracks) {
const seenTrackIds = [];
const result = [];
for (let i = 0; i < tracks.length; i++) {
if (!seenTrackIds.includes(tracks[i].trackId)) {
seenTrackIds.push(tracks[i].trackId);
result.push(tracks[i]);
}
}
return result;
}
function enforceArtistQuota(tracks, maxPerArtist) {
const artists = [];
const artistCounts = [];
const result = [];
for (let i = 0; i < tracks.length; i++) {
const artist = tracks[i].artist;
const artistIndex = artists.indexOf(artist);
if (artistIndex === -1) {
artists.push(artist);
artistCounts.push(1);
result.push(tracks[i]);
} else if (artistCounts[artistIndex] < maxPerArtist) {
artistCounts[artistIndex]++;
result.push(tracks[i]);
}
}
return result;
}
function buildSchedule(tracks) {
const schedule = [];
for (let i = 0; i < tracks.length; i++) {
schedule.push({
slot: i + 1,
trackId: tracks[i].trackId
});
}
return schedule;
}
function remixPlaylist(playlists, maxPerArtist) {
const flattened = flattenPlaylists(playlists);
const scored = scoreTracks(flattened);
const deduped = dedupeTracks(scored);
const limited = enforceArtistQuota(deduped, maxPerArtist);
return buildSchedule(limited);
}
```
@@ -0,0 +1,14 @@
{
"isUpcomingChange": false,
"dashedName": "lab-playlist-remix-engine",
"helpCategory": "JavaScript",
"blockLayout": "link",
"challengeOrder": [
{
"id": "6976db7ecfa770bf21307b20",
"title": "Build a Playlist Remix Engine"
}
],
"blockLabel": "lab",
"usesMultifileEditor": true
}
@@ -137,6 +137,7 @@
"lab-html-entitiy-converter",
"lab-odd-fibonacci-sum-calculator",
"lab-element-skipper",
"lab-playlist-remix-engine",
"review-javascript-fundamentals",
"quiz-javascript-fundamentals"
]