Music Library Insights from JSON

Turn JSON music data into a validated local HTML insights report.

Introduction

30 Second Summary

Listening history can reveal the songs that keep pulling you back. A single missing detail can make those patterns harder to uncover.

In this project, you will use Python to turn a project-owned JSON music library into a browser-ready HTML report. You will make the pipeline resilient enough to handle a track with no album.

What You'll Build

Your final demo opens in a browser with listening-summary cards and a ranked table of five fictional tracks.

By the end of this project, you'll have:

  • A one-command pipeline that processes five music records and confirms the result in your terminal.
  • A resilient data workflow that replaces a missing album with Unknown Album.
  • A browser-ready insights report showing 100 total plays, Luna Vale as the top artist, Night Drives as the top album, and every track ranked by play count.
  • Secret Mission: Extend the music contract with genre data and reveal Synthwave as the top genre with 55 plays.

Are there any prerequisites?

You only need a Mac. This guide includes setup for Python and Visual Studio Code.

Before We Start

Before the hands-on work begins, take a moment to define what your music report will reveal. You will also connect that goal to a reusable JSON core that can support future data adapters.

Set Up the Local Python Workspace

Your reusable JSON core needs a known Python runtime before it can read local music data. Pinning Python 3.15.0 removes environment uncertainty before you create the first file.

Visual Studio Code keeps the project files beside the terminal that runs them. This step prepares both tools so the next step can focus on loading tracks.

In this step, get ready to:
  • Install Python 3.15.0 with its certificates.
  • Install Visual Studio Code 1.141.1.
  • Prepare the music-json-insights workspace for the first data file.
Install Python

Python runs every stage of the music-insights pipeline. Its certificate installer prepares secure connections for tools that Python may contact later.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal.
  • Press Enter to open Terminal.
  • Check for the pinned Python interpreter by running this command:
python3.15

What does this check prove?

  • The python3.15 command starts the installed Python interpreter.
  • The startup banner identifies the exact Python version available on your Mac.

✔️ I see Python 3.15.0

  • Press Control-D to exit the Python prompt.
  • Return to Finder.
  • Select Applications in the Finder sidebar.
  • Open /Applications/Python 3.15/.
  • Double-click Install Certificates.command.

The certificate terminal should end with Successfully installed certifi followed by update complete.

ⓧ I see another Python version

  • Press Control-D to exit the Python prompt.
  • Open the Python 3.15.0 release page.
  • Download the Python 3.15.0 macOS installer.
  • Open the downloaded .pkg file.
  • Complete the standard Installer flow.
  • Select Applications in Finder.
  • Open /Applications/Python 3.15/.
  • Double-click Install Certificates.command.

The certificate terminal should end with Successfully installed certifi followed by update complete.

ⓧ Command not found

  • Open the Python 3.15.0 release page.
  • Download the Python 3.15.0 macOS installer.
  • Open the downloaded .pkg file.
  • Complete the standard Installer flow.
  • Select Applications in Finder.
  • Open /Applications/Python 3.15/.
  • Double-click Install Certificates.command.

The certificate terminal should end with Successfully installed certifi followed by update complete.

Python installation not completing?

  • Confirm that the installer came from the linked Python release page.
  • Check the Downloads folder for the complete .pkg file.
  • Run Install Certificates.command from /Applications/Python 3.15/ after the standard installation finishes.

Help me fix my Python installation.

Install Visual Studio Code

Visual Studio Code provides the editor and integrated terminal for this project. Choose the tab that matches the version currently available on your Mac.

✔️ I see version 1.141.1

  • Return to Finder.
  • Select Applications.
  • Double-click Visual Studio Code.app.
  • Click Code in the macOS menu bar.
  • Select About Visual Studio Code.

The first version number should show 1.141.1.

ⓧ I see an older version

The Applications folder now contains the current Visual Studio Code app.

  • Double-click Visual Studio Code.app in Applications.
  • Click Code in the macOS menu bar.
  • Select About Visual Studio Code.

The first version number should now show 1.141.1.

ⓧ It is not installed yet

The Applications folder now contains Visual Studio Code.

  • Double-click Visual Studio Code.app in Applications.
  • Click Code in the macOS menu bar.
  • Select About Visual Studio Code.

The first version number should show 1.141.1.

Visual Studio Code not opening?

  • Confirm that Visual Studio Code.app is inside Applications.
  • Reopen the downloaded .dmg if the app was left inside the disk image.
  • Download the installer again if the disk image does not open.

Help me finish my Visual Studio Code installation.

Create and verify the workspace

A workspace keeps the script beside its input data. The data subfolder gives the music records a predictable location from the first run.

  • Return to Finder.
  • Click the Desktop.
  • Press Shift+Command+N to create a folder.
  • Type music-json-insights as the folder name.
  • Press Return to save the name.

You should see music-json-insights on your Desktop.

  • Double-click music-json-insights.
  • Press Shift+Command+N to create its subfolder.
  • Type data as the subfolder name.
  • Press Return to save the name.

You should see the data folder inside music-json-insights.

  • Switch back to Visual Studio Code.
  • Click File in the menu bar.
  • Select Open Folder....
  • Select music-json-insights on your Desktop.
  • Click Open.
  • Confirm that you trust the folder if a workspace trust dialog appears.

The Explorer sidebar should show music-json-insights with its data subfolder.

  • Click Terminal in the Visual Studio Code menu bar.
  • Select New Terminal.

The integrated terminal opens at the bottom of Visual Studio Code inside the music-json-insights workspace.

Before you run the final check, what Python version do you expect the banner to show?

  • Start the pinned Python interpreter by running this command:
python3.15

What happens when Python starts?

  • The interpreter prints a version banner that identifies the active Python runtime.
  • The >>> prompt shows that Python is ready to accept instructions.

You should see a Python 3.15.0 banner followed by the >>> prompt. Your exact runtime is ready inside the project workspace.

Still missing the Python prompt?

  • Close Visual Studio Code completely after installing Python.
  • Reopen Visual Studio Code from Applications.
  • Open a fresh integrated terminal before trying the command again.

Help me find the Python interpreter from Visual Studio Code.

  • Press Control-D to exit the Python interpreter.

Your local workspace now has the exact runtime, editor, folder structure, and terminal needed for the pipeline. Next up, you will give it four music tracks to load.

Load the First Four Tracks

Your Python 3.15.0 workspace is ready in Visual Studio Code. Now you'll turn the empty workspace into the first working slice of your music pipeline.

A tiny input-to-output loop makes the pipeline easy to test before analytics or validation add complexity. This step connects four JSON records to one terminal message.

In this step, get ready to:
  • Create a four-track JSON dataset.
  • Build a Python loader for the top-level tracks list.
  • Run the script to confirm that four tracks load.
Create the four-track JSON dataset

A data contract defines the shape that every record follows. Here, the top-level tracks list holds four objects with consistent music fields.

  • Select the existing data folder in the file list on the left side of VS Code.
  • Click the new-file icon at the top of the file list.
  • Type tracks.json into the filename box.
  • Press Enter.

You should see tracks.json inside the data folder.

  • Add the four music records to data/tracks.json by pasting this code:
{
  "tracks": [
    {"title": "Neon Skyline", "artist": "Luna Vale", "album": "Night Drives", "play_count": 34},
    {"title": "Paper Moons", "artist": "Luna Vale", "album": "Night Drives", "play_count": 21},
    {"title": "Low Tide", "artist": "Harbor Echo", "album": "Coastal Lines", "play_count": 18},
    {"title": "Signal Fires", "artist": "Northbound", "album": "Maps at Midnight", "play_count": 15}
  ]
}

What does this data represent?

  • The tracks key gives the loader one predictable place to find every record.
  • Each object stores a title, an artist, an album, plus a play_count value.
  • The integer play_count values prepare the records for calculations later.
  • Save data/tracks.json by pressing Cmd+S on macOS.
  • Confirm that tracks.json remains listed inside the data folder.

Your workspace now contains a project-owned dataset with four complete music records.

Does the dataset look different?

  • Check that tracks.json sits inside the data folder.
  • Compare every quote with the reference because JSON requires double quotes around keys and text values.
  • Confirm that each record except the last ends with a comma.

Help me check my JSON dataset.

✔️ Awesome, I've got everything!

Your four-record contract is saved in data/tracks.json.

ⓧ I'd like to double check the full code

  • Compare your complete data/tracks.json file with this reference:
{
  "tracks": [
    {"title": "Neon Skyline", "artist": "Luna Vale", "album": "Night Drives", "play_count": 34},
    {"title": "Paper Moons", "artist": "Luna Vale", "album": "Night Drives", "play_count": 21},
    {"title": "Low Tide", "artist": "Harbor Echo", "album": "Coastal Lines", "play_count": 18},
    {"title": "Signal Fires", "artist": "Northbound", "album": "Maps at Midnight", "play_count": 15}
  ]
}

What should match?

The file has one top-level tracks list. That list contains the four records in the same order shown above.

Build the JSON loader

The script needs a stable route to the dataset. Path represents that route while json.load(file) converts the file contents into Python data.

  • Select the music-json-insights folder at the top of the file list.
  • Click the new-file icon at the top of the file list.
  • Type myscript.py into the filename box.
  • Press Enter.

You should see myscript.py beside the data folder.

  • Add the dataset path plus the loading function to myscript.py by pasting this code:
import json
from pathlib import Path

DATA_FILE = Path("data/tracks.json")


def load_tracks(path):
    with path.open(encoding="utf-8") as file:
        payload = json.load(file)
    return payload["tracks"]

What does this code do?

  • The imports provide JSON parsing plus file-path handling from the Python standard library.
  • The DATA_FILE constant points to data/tracks.json from the existing workspace folder.
  • The load_tracks(path) function opens the selected file with utf-8 text encoding.
  • The return statement extracts the top-level tracks list from the loaded object.
  • Save myscript.py by pressing Cmd+S on macOS.
  • Confirm that myscript.py is listed beside the data folder.

Your workspace now has a Python loader connected to the JSON file.

Is myscript.py in the wrong folder?

  • Move myscript.py beside the data folder if it appears inside that folder.
  • Confirm that the filename ends with .py.
  • Save the file after correcting its location.

Help me check my Python file location.

Print the loaded track count

The loader returns the records from the file. A small main() function turns that result into a terminal message you can verify.

  • Place your cursor two blank lines below return payload["tracks"] in myscript.py.
  • Add the runnable entry point with this code:
def main():
    tracks = load_tracks(DATA_FILE)
    print(f"Loaded {len(tracks)} tracks.")


if __name__ == "__main__":
    main()

How does the entry point work?

  • The main() function passes DATA_FILE to the loader.
  • The tracks variable holds the list returned from the JSON file.
  • The len(tracks) call counts the four records for the terminal message.
  • The final condition runs main() when you execute this file as a script.
  • Save myscript.py.
  • Switch back to the VS Code integrated terminal from earlier.

Before you run the script, what count do you expect it to print from the current dataset?

  • Test the complete input-to-output loop by running this command:
python3.15 myscript.py

What should you see?

You'll see Loaded 4 tracks. in the terminal.

That's your first working pipeline loop. The script turns the project-owned dataset into a visible count.

Does the script fail to print the count?

  • Confirm that the integrated terminal is still inside the music-json-insights folder.
  • Save both data/tracks.json plus myscript.py.
  • Compare the indentation in load_tracks(path) plus main() with the code above.

Help me troubleshoot the loader.

✔️ Awesome, I've got everything!

Your saved script reads data/tracks.json and prints the four-record count.

ⓧ I'd like to double check the full code

  • Compare your complete myscript.py file with this reference:
import json
from pathlib import Path

DATA_FILE = Path("data/tracks.json")


def load_tracks(path):
    with path.open(encoding="utf-8") as file:
        payload = json.load(file)
    return payload["tracks"]


def main():
    tracks = load_tracks(DATA_FILE)
    print(f"Loaded {len(tracks)} tracks.")


if __name__ == "__main__":
    main()

What should match?

The file imports the two standard-library tools before defining DATA_FILE. The loader appears before the runnable main() entry point.

Your first input-to-output loop works. Next, you'll add an incomplete record to test how the current analysis handles missing data.

Watch a Missing Album Break the Analysis

Your four-track JSON file already loads successfully in Python. You are ready to test the first analysis against less tidy input.

Real datasets often contain incomplete records. This step tests a first-pass album analysis against a track with no album value.

In this step, get ready to:
  • Add a fifth track that has no album field.
  • Aggregate play counts with direct album access.
  • Run the analysis to expose the missing-field failure.
Add an incomplete fifth track

A reusable data contract needs to account for records that arrive without every optional detail. Glass City gives you a realistic record with a title, artist, and play count but no album.

  • Select data/tracks.json in the VS Code file sidebar.
  • Replace its contents by pasting the five-track dataset below:
{
  "tracks": [
    {"title": "Neon Skyline", "artist": "Luna Vale", "album": "Night Drives", "play_count": 34},
    {"title": "Paper Moons", "artist": "Luna Vale", "album": "Night Drives", "play_count": 21},
    {"title": "Low Tide", "artist": "Harbor Echo", "album": "Coastal Lines", "play_count": 18},
    {"title": "Signal Fires", "artist": "Northbound", "album": "Maps at Midnight", "play_count": 15},
    {"title": "Glass City", "artist": "Harbor Echo", "play_count": 12}
  ]
}

What changed in the dataset?

The fifth record omits album while keeping the other track values. This models incomplete input without making the JSON invalid.

  • Save data/tracks.json.
  • Switch back to the integrated terminal from earlier.
  • Confirm that the loader accepts the fifth record by running:
python3.15 myscript.py

What does this check prove?

The script currently counts the records returned by load_tracks(). Seeing Loaded 5 tracks. confirms that the fifth record is valid JSON input.

That is a useful milestone: your loader now accepts all five records, including the incomplete one.

Still seeing four tracks?

Check that the Glass City object sits inside the tracks list. Confirm that a comma separates it from the Signal Fires object.

Save data/tracks.json before running the script again.

Help me find why my script still loads four tracks.

Add the album aggregation

A Counter can collect the play total for each album. The first-pass loop uses each track's album value as the counter key.

  • Select myscript.py in the VS Code file sidebar.
  • Replace the import section at the top of the file with the following group:
import json
from collections import Counter
from pathlib import Path

What do these imports provide?

  • The json module reads the dataset into Python objects.
  • Counter stores a running play total for each album name.
  • Path points the loader to data/tracks.json.
  • Save myscript.py.
  • Confirm that the new import loads successfully by running:
python3.15 myscript.py

What does this check prove?

Seeing Loaded 5 tracks. proves that Python imported Counter successfully. The loader still reads every record.

Did the import stop the script?

Check that from collections import Counter starts at the left edge of myscript.py. Confirm that its spelling matches the code block.

Help me fix the Counter import.

  • Locate the existing main() function in myscript.py.
  • Replace the complete function with this album aggregation:
def main():
    tracks = load_tracks(DATA_FILE)
    album_plays = Counter()
    for track in tracks:
        album_plays[track["album"]] += track["play_count"]
    print(f"Loaded {len(tracks)} tracks.")

What does this code do?

  • album_plays starts as an empty counter for album totals.
  • The loop processes each track from the JSON file.
  • Each pass adds the track's play_count to the counter entry selected by album.
  • Save myscript.py.
Run the fragile analysis

Before you run this, do you expect all five records to make it through the album loop?

  • Test the album aggregation by running:
python3.15 myscript.py

Why did the analysis stop?

You'll see a traceback ending with KeyError: 'album'. Python reaches Glass City before track["album"] requests a key that the record does not contain.

Direct field access assumes that every record has the requested field. The incomplete record has exposed that assumption.

That failure is the result this step set out to uncover. The record loads successfully before the album analysis stops.

Seeing a different result?

If the terminal still ends with Loaded 5 tracks., compare your main() function with the album aggregation above.

If the traceback points to another part of the script, compare both files with the complete references below.

Help me reproduce the missing album failure.

  • Choose the tab that matches how confident you feel about both files.

✔️ Awesome, I've got everything!

Your five-track dataset and fragile album loop are saved. Keep the traceback visible because it proves the missing field reached the analysis.

ⓧ I'd like to double check the full code

  • Compare data/tracks.json with this complete file:
{
  "tracks": [
    {"title": "Neon Skyline", "artist": "Luna Vale", "album": "Night Drives", "play_count": 34},
    {"title": "Paper Moons", "artist": "Luna Vale", "album": "Night Drives", "play_count": 21},
    {"title": "Low Tide", "artist": "Harbor Echo", "album": "Coastal Lines", "play_count": 18},
    {"title": "Signal Fires", "artist": "Northbound", "album": "Maps at Midnight", "play_count": 15},
    {"title": "Glass City", "artist": "Harbor Echo", "play_count": 12}
  ]
}

What should the dataset contain?

The tracks list contains five objects. Glass City is the only object without an album field.

  • Compare myscript.py with this complete file:
import json
from collections import Counter
from pathlib import Path

DATA_FILE = Path("data/tracks.json")


def load_tracks(path):
    with path.open(encoding="utf-8") as file:
        payload = json.load(file)
    return payload["tracks"]


def main():
    tracks = load_tracks(DATA_FILE)
    album_plays = Counter()
    for track in tracks:
        album_plays[track["album"]] += track["play_count"]
    print(f"Loaded {len(tracks)} tracks.")


if __name__ == "__main__":
    main()

What should the script contain?

The script imports Counter before creating album_plays inside main(). The loop accesses each album through track["album"].

You have exposed the exact weak spot in the first-pass analysis. Next, you'll make the input pipeline resilient while keeping all five tracks in the results.

Normalize the Data and Calculate Insights

Your last run exposed the weak point in the current JSON pipeline. Direct access to a missing album ends the script before it can calculate anything.

This step separates input validation from normalization. Once every track has a dependable shape, Python can calculate listening insights without another missing-album crash.

In this step, get ready to:
  • Validate each incoming track before calculations begin.
  • Normalize a missing album to Unknown Album.
  • Calculate totals plus rankings from clean records.
Validate and normalize each track

A data contract defines the shape that each record must follow. Required fields protect the calculations from incomplete input.

The optional album field needs a dependable fallback. Normalization turns a missing value into Unknown Album before aggregation begins.

  • In the Explorer sidebar, click myscript.py.
  • Select everything from the DATA_FILE line through the existing load_tracks() function.
  • Replace that selection with this validation-aware loader:
DATA_FILE = Path("data/tracks.json")
REQUIRED_FIELDS = ("title", "artist", "play_count")


def load_tracks(path):
    with path.open(encoding="utf-8") as file:
        payload = json.load(file)

    if not isinstance(payload, dict) or not isinstance(payload.get("tracks"), list):
        raise ValueError('Expected a top-level "tracks" list.')
    if not payload["tracks"]:
        raise ValueError('The "tracks" list must not be empty.')

    return payload["tracks"]

What does this loader check?

  • REQUIRED_FIELDS records the fields that every track must provide.
  • The first condition confirms that the file contains a top-level tracks list.
  • The second condition rejects an empty list before the analytics try to use it.
  • Place your cursor below the updated load_tracks() function.
  • Add the normalization function with this code:
def normalize_track(track, position):
    if not isinstance(track, dict):
        raise ValueError(f"Track {position} must be a JSON object.")

    missing = [field for field in REQUIRED_FIELDS if field not in track]
    if missing:
        raise ValueError(f"Track {position} is missing: {', '.join(missing)}.")

    title = str(track["title"]).strip()
    artist = str(track["artist"]).strip()
    album = str(track.get("album") or "").strip() or "Unknown Album"
    play_count = track["play_count"]

    if not title or not artist:
        raise ValueError(f"Track {position} needs a non-empty title and artist.")
    if type(play_count) is not int or play_count < 0:
        raise ValueError(f"Track {position} needs a non-negative integer play_count.")

    return {"title": title, "artist": artist, "album": album, "play_count": play_count}

How does normalization protect the pipeline?

  • missing identifies required fields that are absent from a track.
  • title plus artist become trimmed text values.
  • album becomes Unknown Album when the source value is missing or blank.
  • play_count must be a non-negative integer before it can contribute to a total.
  • Save myscript.py.

Before you run this version, do you think defining normalize_track() changes the path used by the existing main() function?

  • Test the current execution path by running this command in the integrated terminal:
python3.15 myscript.py

Why does the old error remain?

The traceback still ends with KeyError: 'album'. The current main() function still sends raw tracks into the fragile album loop.

This expected result proves that a normalization function only protects data once the execution path uses it. The next change connects that safer path.

Build the listening insights

Normalized tracks give every calculation the same dependable fields. The analytics can now aggregate artists plus albums without checking for missing values each time.

  • Place your cursor below the normalize_track() function.
  • Add the insight builder with this code:
def build_insights(tracks):
    artist_plays = Counter()
    album_plays = Counter()

    for track in tracks:
        artist_plays[track["artist"]] += track["play_count"]
        album_plays[track["album"]] += track["play_count"]

    ranked_tracks = sorted(
        tracks,
        key=lambda track: (-track["play_count"], track["title"].lower()),
    )

    return {
        "track_count": len(tracks),
        "total_plays": sum(track["play_count"] for track in tracks),
        "top_artist": artist_plays.most_common(1)[0],
        "top_album": album_plays.most_common(1)[0],
        "ranked_tracks": ranked_tracks,
    }

What insights does this calculate?

  • artist_plays totals the play counts for each artist.
  • album_plays totals the play counts for each normalized album.
  • ranked_tracks orders tracks by play count from highest to lowest.
  • The returned insights data includes summary values plus the ranked records.
  • Select the existing main() function in myscript.py.
  • Replace that function with this normalized execution path:
def main():
    raw_tracks = load_tracks(DATA_FILE)
    tracks = [normalize_track(track, position) for position, track in enumerate(raw_tracks, start=1)]
    insights = build_insights(tracks)

    print(f"Loaded {insights['track_count']} valid tracks.")
    print(f"Total plays: {insights['total_plays']}")

How does the pipeline flow now?

  • raw_tracks holds the records exactly as they appear in data/tracks.json.
  • tracks holds the validated records returned by normalize_track().
  • insights holds the totals plus rankings returned by build_insights().
  • Save myscript.py.

✔️ Awesome, I've got everything!

Your data/tracks.json file stays unchanged. Double-check that you saved myscript.py.

ⓧ I'd like to double check the full code

Leave data/tracks.json unchanged from the previous step. Compare your complete myscript.py file with this reference.

import json
from collections import Counter
from pathlib import Path

DATA_FILE = Path("data/tracks.json")
REQUIRED_FIELDS = ("title", "artist", "play_count")


def load_tracks(path):
    with path.open(encoding="utf-8") as file:
        payload = json.load(file)

    if not isinstance(payload, dict) or not isinstance(payload.get("tracks"), list):
        raise ValueError('Expected a top-level "tracks" list.')
    if not payload["tracks"]:
        raise ValueError('The "tracks" list must not be empty.')

    return payload["tracks"]


def normalize_track(track, position):
    if not isinstance(track, dict):
        raise ValueError(f"Track {position} must be a JSON object.")

    missing = [field for field in REQUIRED_FIELDS if field not in track]
    if missing:
        raise ValueError(f"Track {position} is missing: {', '.join(missing)}.")

    title = str(track["title"]).strip()
    artist = str(track["artist"]).strip()
    album = str(track.get("album") or "").strip() or "Unknown Album"
    play_count = track["play_count"]

    if not title or not artist:
        raise ValueError(f"Track {position} needs a non-empty title and artist.")
    if type(play_count) is not int or play_count < 0:
        raise ValueError(f"Track {position} needs a non-negative integer play_count.")

    return {"title": title, "artist": artist, "album": album, "play_count": play_count}


def build_insights(tracks):
    artist_plays = Counter()
    album_plays = Counter()

    for track in tracks:
        artist_plays[track["artist"]] += track["play_count"]
        album_plays[track["album"]] += track["play_count"]

    ranked_tracks = sorted(
        tracks,
        key=lambda track: (-track["play_count"], track["title"].lower()),
    )

    return {
        "track_count": len(tracks),
        "total_plays": sum(track["play_count"] for track in tracks),
        "top_artist": artist_plays.most_common(1)[0],
        "top_album": album_plays.most_common(1)[0],
        "ranked_tracks": ranked_tracks,
    }


def main():
    raw_tracks = load_tracks(DATA_FILE)
    tracks = [normalize_track(track, position) for position, track in enumerate(raw_tracks, start=1)]
    insights = build_insights(tracks)

    print(f"Loaded {insights['track_count']} valid tracks.")
    print(f"Total plays: {insights['total_plays']}")


if __name__ == "__main__":
    main()
Verify the normalized results

Before you run the finished pipeline, what do you expect to happen when it reaches Glass City without an album?

  • Run the completed analytics pipeline in the integrated terminal with this command:
python3.15 myscript.py

What should I see?

The terminal prints Loaded 5 valid tracks. on the first line. It prints Total plays: 100 on the second line.

The traceback is gone because Glass City now receives the normalized album value Unknown Album before aggregation.

You closed the exact gap exposed by the missing album. All five tracks now reach the analytics layer as valid records.

Still seeing a traceback?

  • Check that main() passes every raw record through normalize_track().
  • Remove the old album loop that processed raw tracks inside main().
  • Confirm that album uses track.get("album") inside normalize_track().

Help me debug the normalization pipeline

Your pipeline now turns imperfect records into dependable listening insights. Next, you will transform those results into a browser-ready report.

Generate the Browser Report

Your Python pipeline now turns five imperfect JSON records into trustworthy insights. Those terminal totals prove the analysis works.

Terminal output is difficult to demonstrate to someone else. This step turns the same insights into a polished HTML report while escaping track text before it reaches the page.

In this step, get ready to:
  • Convert the ranked tracks into safe HTML table rows.
  • Build a styled report with three listening-summary cards.
  • Generate report.html and open it in your browser.
Escape the ranked track rows

Track names come from the input file. The html.escape() function converts characters with markup meaning into safe text before each value enters a table cell.

  • In the open myscript.py file, select the import section plus the constants at the top.
  • Replace the selected section with this code:
import html
import json
from collections import Counter
from pathlib import Path

DATA_FILE = Path("data/tracks.json")
REPORT_FILE = Path("report.html")
REQUIRED_FIELDS = ("title", "artist", "play_count")

What Does This Set Up?

  • The html module provides the escaping function used by the report renderer.
  • The REPORT_FILE path keeps the generated report in the same music-json-insights folder as your script.
  • Place your cursor below the closing brace of the build_insights() function.
  • Paste this table-row function at that location:
def render_rows(tracks):
    rows = []
    for rank, track in enumerate(tracks, start=1):
        rows.append(
            f"""
            <tr>
              <td>{rank}</td>
              <td>{html.escape(track['title'])}</td>
              <td>{html.escape(track['artist'])}</td>
              <td>{html.escape(track['album'])}</td>
              <td>{track['play_count']}</td>
            </tr>
            """
        )
    return "".join(rows)

How Does the Row Renderer Work?

  • The enumerate() call assigns each ranked track a visible position starting at 1.
  • The html.escape() calls protect the title, artist, and album cells from being interpreted as markup.
  • The final join() call combines every row into one string for the report template.

Seeing an Indentation Problem?

Confirm that render_rows() starts at the left edge of the file. Keep the loop contents indented inside the function.

Check that every opening parenthesis has a matching closing parenthesis. The final return line belongs inside the function.

Help me check the render_rows function

Build the report page

The report template combines your verified insight values with a responsive layout. Its summary cards surface the totals first while the table preserves the complete ranking below.

  • Place your cursor below the render_rows() function.
  • Paste the first part of build_report() at that location:
def build_report(insights):
    top_artist_name, top_artist_plays = insights["top_artist"]
    top_album_name, top_album_plays = insights["top_album"]
    rows = render_rows(insights["ranked_tracks"])

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Music Library Insights</title>
  <style>
    :root {{ color-scheme: dark; }}
    * {{ box-sizing: border-box; }}
    body {{ margin: 0; font-family: system-ui, sans-serif; background: #0b1020; color: #f8fafc; }}
    main {{ width: min(960px, 92vw); margin: 0 auto; padding: 56px 0; }}
    .eyebrow {{ color: #7dd3fc; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.12em; }}
    h1 {{ margin: 8px 0 12px; font-size: clamp(2rem, 5vw, 3.5rem); }}
    .subtitle {{ color: #a5b4fc; margin-bottom: 32px; }}
    .cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 16px; margin-bottom: 32px; }}
    .card {{ background: #151d35; border: 1px solid #293553; border-radius: 16px; padding: 20px; }}
    .card span {{ color: #94a3b8; font-size: 0.85rem; }}
    .card strong {{ display: block; margin-top: 8px; font-size: 1.45rem; }}
    .card small {{ color: #7dd3fc; }}
    .table-wrap {{ overflow-x: auto; border-radius: 16px; }}
    table {{ width: 100%; border-collapse: collapse; background: #11182c; }}
    th, td {{ padding: 14px 16px; text-align: left; border-bottom: 1px solid #25304b; }}
    th {{ color: #a5b4fc; font-size: 0.8rem; text-transform: uppercase; }}
    tbody tr:hover {{ background: #17213a; }}
  </style>

What Does the First Part Build?

  • The tuple assignments separate each top insight into a display name plus its play count.
  • The rows value contains the escaped table markup returned by render_rows().
  • The embedded styles create the dark layout, responsive cards, and scrollable table used by the finished report.
  • Place your cursor directly after the </style> line.
  • Complete the same template by pasting this second part:
</head>
<body>
  <main>
    <p class="eyebrow">LOCAL JSON REPORT</p>
    <h1>Music Library Insights</h1>
    <p class="subtitle">{insights['track_count']} normalized tracks, ranked by play count.</p>
    <section class="cards" aria-label="Listening summary">
      <article class="card"><span>Total plays</span><strong>{insights['total_plays']}</strong></article>
      <article class="card"><span>Top artist</span><strong>{html.escape(top_artist_name)}</strong><small>{top_artist_plays} plays</small></article>
      <article class="card"><span>Top album</span><strong>{html.escape(top_album_name)}</strong><small>{top_album_plays} plays</small></article>
    </section>
    <div class="table-wrap">
      <table>
        <thead><tr><th>Rank</th><th>Track</th><th>Artist</th><th>Album</th><th>Plays</th></tr></thead>
        <tbody>{rows}</tbody>
      </table>
    </div>
  </main>
</body>
</html>
"""

What Does the Finished Template Show?

  • The first card displays the combined play count from total_plays.
  • The next two cards display the most-played artist and album with their totals.
  • The table body inserts the complete ranked row string below five column headings.
  • The top artist and album names pass through html.escape() before appearing in the cards.

Does the Template Look Incomplete?

Check that the second piece begins immediately after </style>. The closing triple quotes must appear after </html>.

Confirm that every CSS brace remains doubled inside the formatted string. Single braces would be treated as Python expressions.

Help me check the build_report template

Write and open the report

The report functions now return one complete page. The final connection writes that page to report.html before the script prints its resolved location.

  • Select the existing main() function in myscript.py.
  • Replace the selected function with this version:
def main():
    raw_tracks = load_tracks(DATA_FILE)
    tracks = [normalize_track(track, position) for position, track in enumerate(raw_tracks, start=1)]
    insights = build_insights(tracks)
    REPORT_FILE.write_text(build_report(insights), encoding="utf-8")

    print(f"Loaded {insights['track_count']} valid tracks.")
    print(f"Total plays: {insights['total_plays']}")
    print(f"Report written to {REPORT_FILE.resolve()}")

How Does Main Generate the File?

  • The build_report() call converts the insight dictionary into the complete page string.
  • The write_text() call saves that string as UTF-8 text in report.html.
  • The resolve() call prints the complete local path so you know exactly where the report was created.
  • Save myscript.py.

✔️ Awesome, I've got everything!

Your saved myscript.py now contains the row renderer, page renderer, and report-writing step.

ⓧ I'd like to double check the full code

Compare your complete myscript.py with this reference. Your existing data/tracks.json file stays unchanged.

import html
import json
from collections import Counter
from pathlib import Path

DATA_FILE = Path("data/tracks.json")
REPORT_FILE = Path("report.html")
REQUIRED_FIELDS = ("title", "artist", "play_count")


def load_tracks(path):
    with path.open(encoding="utf-8") as file:
        payload = json.load(file)

    if not isinstance(payload, dict) or not isinstance(payload.get("tracks"), list):
        raise ValueError('Expected a top-level "tracks" list.')
    if not payload["tracks"]:
        raise ValueError('The "tracks" list must not be empty.')

    return payload["tracks"]


def normalize_track(track, position):
    if not isinstance(track, dict):
        raise ValueError(f"Track {position} must be a JSON object.")

    missing = [field for field in REQUIRED_FIELDS if field not in track]
    if missing:
        raise ValueError(f"Track {position} is missing: {', '.join(missing)}.")

    title = str(track["title"]).strip()
    artist = str(track["artist"]).strip()
    album = str(track.get("album") or "").strip() or "Unknown Album"
    play_count = track["play_count"]

    if not title or not artist:
        raise ValueError(f"Track {position} needs a non-empty title and artist.")
    if type(play_count) is not int or play_count < 0:
        raise ValueError(f"Track {position} needs a non-negative integer play_count.")

    return {"title": title, "artist": artist, "album": album, "play_count": play_count}


def build_insights(tracks):
    artist_plays = Counter()
    album_plays = Counter()

    for track in tracks:
        artist_plays[track["artist"]] += track["play_count"]
        album_plays[track["album"]] += track["play_count"]

    ranked_tracks = sorted(
        tracks,
        key=lambda track: (-track["play_count"], track["title"].lower()),
    )

    return {
        "track_count": len(tracks),
        "total_plays": sum(track["play_count"] for track in tracks),
        "top_artist": artist_plays.most_common(1)[0],
        "top_album": album_plays.most_common(1)[0],
        "ranked_tracks": ranked_tracks,
    }


def render_rows(tracks):
    rows = []
    for rank, track in enumerate(tracks, start=1):
        rows.append(
            f"""
            <tr>
              <td>{rank}</td>
              <td>{html.escape(track['title'])}</td>
              <td>{html.escape(track['artist'])}</td>
              <td>{html.escape(track['album'])}</td>
              <td>{track['play_count']}</td>
            </tr>
            """
        )
    return "".join(rows)


def build_report(insights):
    top_artist_name, top_artist_plays = insights["top_artist"]
    top_album_name, top_album_plays = insights["top_album"]
    rows = render_rows(insights["ranked_tracks"])

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Music Library Insights</title>
  <style>
    :root {{ color-scheme: dark; }}
    * {{ box-sizing: border-box; }}
    body {{ margin: 0; font-family: system-ui, sans-serif; background: #0b1020; color: #f8fafc; }}
    main {{ width: min(960px, 92vw); margin: 0 auto; padding: 56px 0; }}
    .eyebrow {{ color: #7dd3fc; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.12em; }}
    h1 {{ margin: 8px 0 12px; font-size: clamp(2rem, 5vw, 3.5rem); }}
    .subtitle {{ color: #a5b4fc; margin-bottom: 32px; }}
    .cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 16px; margin-bottom: 32px; }}
    .card {{ background: #151d35; border: 1px solid #293553; border-radius: 16px; padding: 20px; }}
    .card span {{ color: #94a3b8; font-size: 0.85rem; }}
    .card strong {{ display: block; margin-top: 8px; font-size: 1.45rem; }}
    .card small {{ color: #7dd3fc; }}
    .table-wrap {{ overflow-x: auto; border-radius: 16px; }}
    table {{ width: 100%; border-collapse: collapse; background: #11182c; }}
    th, td {{ padding: 14px 16px; text-align: left; border-bottom: 1px solid #25304b; }}
    th {{ color: #a5b4fc; font-size: 0.8rem; text-transform: uppercase; }}
    tbody tr:hover {{ background: #17213a; }}
  </style>
</head>
<body>
  <main>
    <p class="eyebrow">LOCAL JSON REPORT</p>
    <h1>Music Library Insights</h1>
    <p class="subtitle">{insights['track_count']} normalized tracks, ranked by play count.</p>
    <section class="cards" aria-label="Listening summary">
      <article class="card"><span>Total plays</span><strong>{insights['total_plays']}</strong></article>
      <article class="card"><span>Top artist</span><strong>{html.escape(top_artist_name)}</strong><small>{top_artist_plays} plays</small></article>
      <article class="card"><span>Top album</span><strong>{html.escape(top_album_name)}</strong><small>{top_album_plays} plays</small></article>
    </section>
    <div class="table-wrap">
      <table>
        <thead><tr><th>Rank</th><th>Track</th><th>Artist</th><th>Album</th><th>Plays</th></tr></thead>
        <tbody>{rows}</tbody>
      </table>
    </div>
  </main>
</body>
</html>
"""


def main():
    raw_tracks = load_tracks(DATA_FILE)
    tracks = [normalize_track(track, position) for position, track in enumerate(raw_tracks, start=1)]
    insights = build_insights(tracks)
    REPORT_FILE.write_text(build_report(insights), encoding="utf-8")

    print(f"Loaded {insights['track_count']} valid tracks.")
    print(f"Total plays: {insights['total_plays']}")
    print(f"Report written to {REPORT_FILE.resolve()}")


if __name__ == "__main__":
    main()

How to Compare the File

Check the order of the imports, constants, functions, and final main() call. Blank lines help separate each function without changing how the script runs.

Before you run it, predict the new line the terminal will add after the two existing totals.

  • Generate the browser report by running this command in the integrated terminal:
python3.15 myscript.py

What Should the Terminal Confirm?

You should see Loaded 5 valid tracks. followed by Total plays: 100.

A third line starts with Report written to and ends with the resolved path to report.html.

The terminal has now confirmed that the same five validated records produced a real report file.

Report File Missing?

Check that REPORT_FILE.write_text() appears inside main() after build_insights().

If the script stops before printing the report path, compare the referenced line in myscript.py with the full-file tab above.

Help me generate report.html

Before you open the file, predict which track should occupy the first row of the ranked table.

  • Open Finder from the macOS Dock.
  • Use Finder search to locate the music-json-insights folder.
  • Double-click report.html inside that folder.

Your default browser opens a dark Music Library Insights page. You should see three summary cards above a five-row ranked table.

The cards show 100 total plays, Luna Vale with 55 plays, and Night Drives with 55 plays.

Browser Showing Raw Text or a Blank Page?

Confirm that you opened the generated report.html file. The source file myscript.py should remain open in your editor.

Run the script again after saving your latest changes. Reopen the regenerated report from the path printed in the terminal.

Help me check my browser report

You made the pipeline visible. Your five validated tracks now become a safe browser report that you can open again whenever the dataset changes.

Secret mission

Reveal the Top Genre

Extend your music data contract with genre values. Carry genre through validation, aggregation, and reporting so your browser reveals Synthwave as the top genre with 55 plays.

Clean Up Your Resources

Clean Up Your Resources

This project uses only local files, so there are no ongoing costs. Decide whether to keep your resources available, pause your work, or delete them entirely.

Resources you used:

  • The local music-json-insights project folder.
  • The data/tracks.json dataset with five genre-tagged music records.
  • The myscript.py analytics script.
  • The generated report.html browser report.

Keep everything running

No action is needed. Choose this option if you want to replace the fictional records with another JSON dataset later.

  • Keep the music-json-insights folder in its current location.
  • Use data/tracks.json as the starting point for your next music dataset.

Pause - I'll come back to this later

Close the open apps while keeping every project file intact. Nothing from this project runs in the background.

  • Close the browser tab showing report.html.
  • Close Visual Studio Code.

Delete - I don't want to use this again

Deleting the project folder removes every local resource from this project in one move. Keep a copy first if you want to reuse your report.

  • Close the browser tab showing report.html.
  • Close Visual Studio Code.
  • Click Finder in the Dock.
  • Locate the music-json-insights folder.
  • Drag the music-json-insights folder to Trash.
  • Click Trash in the Dock.

You should see music-json-insights in Trash. The folder no longer appears in its previous location.

Nice Work!

Nice Work!

You did it! Your Python pipeline now validates JSON music records before generating a browser-ready HTML report.

You've learned how to:

  • Create a project-owned JSON data contract for five music records. Preserve records with an optional album field through normalization.
  • Trace a predictable KeyError: 'album' to fragile field access. Replace that access with defensive validation. Normalize missing album values to Unknown Album.
  • Generate a safe browser report with escaped track text. Its summary cards show 100 total plays. They identify Luna Vale as the top artist. They identify Night Drives as the top album. Its table ranks all five tracks.
  • Secret Mission: Extend the JSON contract with required genre data. Add a Top genre card. Confirm Synthwave leads with 55 plays.

Ready to quiz yourself?