5. Connecting your data and the OAuth flow

The dashboard now needs to connect to the work you already built: the SQLite database from Chapter 16 and the Spotify OAuth flow from Chapter 14. This page wires both into Flask.

By the end, the app can read the Music Time Machine database, redirect users to Spotify, receive the OAuth callback, store tokens in the session, refresh expired tokens, and keep pages from crashing when data is missing.

Read the database Chapter 16 created

Chapter 16 wrote data into music_time_machine.db. The dashboard reads from that file and turns the stored data into cards, charts, and tables.

The main tables are:

  • tracks: the songs the project has seen, including the cached raw_json payload.
  • snapshots: the historical top-track records.

Start app.py with the Flask imports, the Spotify client, and the database path.

app.py
# app.py
import os
import secrets
import time
from functools import wraps

from flask import (
    Flask, render_template, session, redirect, url_for,
    request, flash,
)
from dotenv import load_dotenv

from spotify_client import SpotifyClient  # web-side OAuth client, defined later on this page

load_dotenv()

app = Flask(__name__)
app.secret_key = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')

DATABASE_PATH = 'music_time_machine.db'  # the SQLite file Ch16 wrote

A route can now check whether the user is logged in, load dashboard data, and render the template.

app.py
@app.route('/')
def home():
    if 'access_token' not in session:
        return redirect(url_for('login'))

    stats = calculate_taste_stats()
    chart_data = get_taste_chart_data()
    return render_template('home.html', stats=stats, chart_data=chart_data)

Keep that split clear: routes handle browser requests; helper functions read and shape the data.

Protect pages that require Spotify login

The dashboard should not show private Spotify pages unless the user has connected their account. A small decorator keeps that check out of every route.

app.py
from functools import wraps

def require_auth(f):
    """Redirect to /login if no access_token is in the session."""
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'access_token' not in session:
            # Remember where they were headed so /callback can return them.
            session['next_url'] = request.url
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function


@app.route('/')
@require_auth
def home():
    """Home dashboard -- protected by @require_auth."""
    stats = calculate_taste_stats()
    chart_data = get_taste_chart_data()
    return render_template('home.html', stats=stats, chart_data=chart_data)

If the session contains an access token, the route runs. If not, Flask sends the user to /login and remembers where they were trying to go.

Use Flask sessions carefully

Flask stores its default session in a signed browser cookie. Signed means the user cannot change it without breaking the signature. It does not mean the contents are hidden.

Signed, not encrypted

Do not put sensitive long-term secrets in the default Flask session. For this local dashboard, short-lived OAuth values are acceptable. In production, store tokens server-side.

The signing key must be random, secret, and stable. Generate a strong value and load it from an environment variable:

Terminal
python -c "import secrets; print(secrets.token_hex(32))"

Add the Spotify OAuth client

The web version of OAuth needs three operations: build the Spotify login URL, exchange the callback code for tokens, and refresh the access token later.

Save this as spotify_client.py.

spotify_client.py
# spotify_client.py
import os
from urllib.parse import urlencode

import requests
from dotenv import load_dotenv

load_dotenv()


class SpotifyClient:
    """Browser-side OAuth client for Spotify's authorization-code flow."""

    AUTHORIZE_URL = 'https://accounts.spotify.com/authorize'
    TOKEN_URL = 'https://accounts.spotify.com/api/token'

    def __init__(self):
        self.client_id = os.getenv('SPOTIFY_CLIENT_ID')
        self.client_secret = os.getenv('SPOTIFY_CLIENT_SECRET')

        if not self.client_id or not self.client_secret:
            raise RuntimeError(
                'Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET in your environment.'
            )

    def get_authorization_url(self, redirect_uri, scope, state):
        """Build the Spotify authorize URL the browser is redirected to."""
        params = {
            'client_id': self.client_id,
            'response_type': 'code',
            'redirect_uri': redirect_uri,
            'scope': scope,
            'state': state, # echoed back to /callback for the CSRF check
        }
        return f"{self.AUTHORIZE_URL}?{urlencode(params)}"

    def exchange_code_for_token(self, code, redirect_uri):
        """Trade the auth code for an access_token + refresh_token pair."""
        response = requests.post(
            self.TOKEN_URL,
            data={
                'grant_type': 'authorization_code',
                'code': code,
                'redirect_uri': redirect_uri,
            },
            auth=(self.client_id, self.client_secret),
            timeout=10,
        )
        response.raise_for_status()
        return response.json()

    def refresh_access_token(self, refresh_token):
        """Use the refresh_token to mint a new access_token before expiry."""
        response = requests.post(
            self.TOKEN_URL,
            data={
                'grant_type': 'refresh_token',
                'refresh_token': refresh_token,
            },
            auth=(self.client_id, self.client_secret),
            timeout=10,
        )
        response.raise_for_status()
        return response.json()

Start the OAuth flow

The /login route creates a one-time state value, stores it in the session, and sends the user to Spotify.

app.py
@app.route('/login')
def login():
    """Begin the OAuth flow by redirecting to Spotify with a CSRF state token."""
    # Generate a one-time anti-CSRF state value, stash it in the session, and
    # send the same value to Spotify in the auth URL. The /callback route
    # checks that what comes back matches what we stored.
    state = secrets.token_urlsafe(32)
    session['oauth_state'] = state

    spotify = SpotifyClient()
    auth_url = spotify.get_authorization_url(
        redirect_uri='http://127.0.0.1:5000/callback',
        scope='user-top-read playlist-modify-public playlist-modify-private',
        state=state,
    )
    return redirect(auth_url)

Handle the callback

After the user approves the app, Spotify redirects back to /callback. The route checks the state value, handles the case where the user cancelled (Spotify sends back an error parameter instead of a code), exchanges the code for tokens, and sends the user back to the page they originally wanted.

app.py
@app.route('/callback')
def callback():
    """Verify state, exchange the auth code for tokens, and resume the flow."""
    # CSRF defence: pop the state we issued in /login and compare against
    # what Spotify echoed back. pop() makes the state single-use.
    expected_state = session.pop('oauth_state', None)
    received_state = request.args.get('state')
    if not expected_state or received_state != expected_state:
        flash('Authentication failed: state mismatch. Please try again.', 'error')
        return redirect(url_for('login'))

    # If the user clicked Cancel on the consent screen, Spotify redirects
    # back with ?error=access_denied (and no code). Handle that explicitly
    # so the message is clearer than "no authorization code returned".
    error = request.args.get('error')
    if error:
        flash(f'Authentication cancelled: {error}.', 'error')
        return redirect(url_for('login'))

    code = request.args.get('code')
    if not code:
        flash('Authentication failed: no authorization code returned.', 'error')
        return redirect(url_for('login'))

    try:
        spotify = SpotifyClient()
        token_data = spotify.exchange_code_for_token(
            code=code,
            redirect_uri='http://127.0.0.1:5000/callback',
        )
    except Exception as e:
        print(f"Token exchange failed: {e}")
        flash('Authentication failed during token exchange.', 'error')
        return redirect(url_for('login'))

    # The cookie is signed (not encrypted) -- see the warning above.
    session['access_token'] = token_data['access_token']
    session['refresh_token'] = token_data.get('refresh_token')
    session['token_expires_at'] = time.time() + token_data['expires_in']

    # Send the reader back to wherever they were headed before /login.
    next_url = session.pop('next_url', url_for('home'))
    return redirect(next_url)


@app.route('/logout')
def logout():
    """Clear the session and bounce back to home."""
    session.clear()
    return redirect(url_for('home'))

Refresh tokens before they expire

Spotify access tokens expire. The app can refresh them automatically before each request, as long as a refresh token is available.

app.py
@app.before_request
def refresh_token_if_needed():
    """Refresh the Spotify access token a few minutes before it expires."""
    if 'access_token' not in session or 'refresh_token' not in session:
        return

    expires_at = session.get('token_expires_at', 0)
    if time.time() < expires_at - 300:  # 5-minute buffer
        return

    try:
        spotify = SpotifyClient()
        new_tokens = spotify.refresh_access_token(session['refresh_token'])
    except Exception as e:
        print(f"Token refresh failed: {e}")
        for key in ('access_token', 'refresh_token', 'token_expires_at',
                    'oauth_state', 'next_url'):
            session.pop(key, None)
        flash('Your Spotify session expired. Please log in again.', 'warning')
        return redirect(url_for('login'))

    session['access_token'] = new_tokens['access_token']
    # Spotify only returns a new refresh_token sometimes; keep the old one if not.
    if new_tokens.get('refresh_token'):
        session['refresh_token'] = new_tokens['refresh_token']
    session['token_expires_at'] = time.time() + new_tokens['expires_in']

The small buffer prevents a token from expiring in the middle of a page load. If Spotify rejects the refresh token, the app clears the broken session and sends the user back to login instead of turning every page into a 500 error.

Keep routes safe when data is missing

During development, the database may be empty, half-filled, or missing optional columns. Routes should handle that gracefully instead of crashing the whole dashboard.

This fragile version assumes everything exists. It is an illustration of the failure mode, not a replacement for the real home() route:

Python: fragile home route
def fragile_home_example():
    """Home dashboard - FRAGILE VERSION."""
    # Query database without error handling
    conn = sqlite3.connect('music_time_machine.db')
    cursor = conn.cursor()

    cursor.execute('SELECT COUNT(*) FROM tracks')
    tracks_tracked = cursor.fetchone()[0]

    cursor.execute("""
        SELECT COUNT(DISTINCT snapshot_date) FROM snapshots
        WHERE time_range = 'short_term'
    """)
    total_snapshots = cursor.fetchone()[0]

    conn.close()

    return render_template('home.html',
                          tracks_tracked=tracks_tracked,
                          total_snapshots=total_snapshots)

This defensive version gives the template safe defaults, catches database errors, and still renders the page. It uses a distinct function name because it demonstrates the pattern without replacing the working dashboard route you built earlier:

Python: defensive home route
import sqlite3
from flask import Flask, render_template, flash, redirect, url_for

def defensive_home_example():
    """Home dashboard - DEFENSIVE VERSION."""
    tracks_tracked = 0
    total_snapshots = 0
    try:
        conn = sqlite3.connect('music_time_machine.db', timeout=10)
        cursor = conn.cursor()

        cursor.execute('SELECT COUNT(*) FROM tracks')
        tracks_tracked = cursor.fetchone()[0]

        cursor.execute("""
            SELECT COUNT(DISTINCT snapshot_date) FROM snapshots
            WHERE time_range = 'short_term'
        """)
        total_snapshots = cursor.fetchone()[0]

        conn.close()

    except sqlite3.OperationalError as e:
        # Database locked, missing file, or table doesn't exist yet.
        if 'locked' in str(e):
            flash('Database is temporarily locked. Try again in a moment.', 'warning')
        elif 'no such table' in str(e):
            flash('No snapshot data yet. Run a Chapter 16 snapshot to populate the dashboard.', 'info')
        else:
            flash(f'Database error: {str(e)}', 'error')

    except sqlite3.DatabaseError:
        # Corrupted database or serious storage error.
        flash('Database error. Check the Flask terminal for details.', 'error')

    except Exception as e:
        # Unexpected error -- log it and show a generic message.
        print(f"Unexpected error in home route: {e}")
        flash('Something went wrong. Please try again.', 'error')

    return render_template('home.html',
                          tracks_tracked=tracks_tracked,
                          total_snapshots=total_snapshots)

The same idea applies to analytics pages. Use defaults for missing values and check for NULL before formatting results.

Python: defensive analytics route
def defensive_analytics_example():
    """Analytics page with defensive NULL handling."""
    artist_diversity = 0
    top_artists = []
    try:
        conn = sqlite3.connect('music_time_machine.db', timeout=10)
        cursor = conn.cursor()

        # COUNT(DISTINCT ...) over an empty join returns 0, which is safe to display.
        cursor.execute("""
            SELECT COUNT(DISTINCT t.artist_name)
            FROM tracks t
            JOIN snapshots s ON s.track_id = t.track_id
            WHERE s.time_range = 'short_term'
        """)
        result = cursor.fetchone()
        artist_diversity = result[0] if result else 0

        # JOIN may return zero rows if the snapshots table is empty.
        cursor.execute("""
            SELECT t.artist_name, COUNT(DISTINCT s.snapshot_date) AS appearances
            FROM tracks t
            JOIN snapshots s ON s.track_id = t.track_id
            WHERE s.time_range = 'short_term'
            GROUP BY t.artist_name
            ORDER BY appearances DESC
            LIMIT 10
        """)
        top_artists = cursor.fetchall()

        conn.close()

    except sqlite3.Error as e:
        flash(f'Could not load analytics: {str(e)}', 'error')

    return render_template('analytics.html',
                          artist_diversity=artist_diversity,
                          top_artists=top_artists)

Add a friendly 500 page

A custom error page is still useful. It gives users a clear message when something unexpected goes wrong.

app.py
@app.errorhandler(500)
def internal_error(error):
    """Handle internal server errors."""
    # Log the error for debugging (in production, use proper logging)
    print(f"Internal error: {error}")
    
    # Return clean error page
    return render_template('500.html'), 500

Create the matching template:

templates/500.html


{% extends "base.html" %}

{% block content %}
<div class="error-container">
  <h1>Something went wrong</h1>
  <p>Something broke while serving this page. Check the Flask terminal for the traceback, then return to the dashboard when you are ready.</p>
  
  <div class="error-actions">
    <a href="{{ url_for('home') }}" class="btn btn-primary">
      Go to dashboard
    </a>
  </div>
</div>
{% endblock %}

The 404 handler needs its own template too:

templates/404.html


{% extends "base.html" %}

{% block content %}
<div class="error-container">
  <h1>Page not found</h1>
  <p>This route does not exist yet, or the URL is wrong.</p>

  <div class="error-actions">
    <a href="{{ url_for('home') }}" class="btn btn-primary">
      Go to dashboard
    </a>
  </div>
</div>
{% endblock %}

The complete app.py

Here is the dashboard code up to this point in one file.

app.py
# app.py -- the complete Home Dashboard build to this point.
import os
import requests
import secrets
import sqlite3
import time
from datetime import datetime
from functools import wraps

from flask import (
    Flask, render_template, session, redirect, url_for,
    request, flash,
)
from flask_wtf.csrf import CSRFProtect
from dotenv import load_dotenv

from spotify_client import SpotifyClient

load_dotenv()

app = Flask(__name__)
app.secret_key = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
csrf = CSRFProtect(app)

DATABASE_PATH = 'music_time_machine.db'
SPOTIFY_REDIRECT_URI = os.getenv(
    'SPOTIFY_REDIRECT_URI',
    'http://127.0.0.1:5000/callback',
)


@app.context_processor
def inject_static_version():
    def static_version(filename):
        path = os.path.join(app.root_path, 'static', filename)

        try:
            version = int(os.path.getmtime(path))
        except OSError:
            version = 0

        return f"{url_for('static', filename=filename)}?v={version}"

    return {'static_version': static_version}


# ---------------------------------------------------------------------------
# Database helpers
# ---------------------------------------------------------------------------

def get_db_connection():
    try:
        conn = sqlite3.connect(DATABASE_PATH, timeout=10)
        conn.row_factory = sqlite3.Row
        return conn
    except sqlite3.Error as e:
        print(f"Database connection error: {e}")
        return None


def _format_month(yyyy_mm):
    return datetime.strptime(yyyy_mm, '%Y-%m').strftime('%b %Y')


def calculate_taste_stats():
    empty = {
        'tracks_tracked': 0,
        'turnover_pct': None,
        'turnover_subtext': 'No snapshots yet -- run a Ch16 snapshot to populate.',
        'consistent_artist': None,
        'consistent_subtext': 'No snapshots yet.',
    }
    conn = get_db_connection()
    if not conn:
        return empty
    try:
        cursor = conn.cursor()
        cursor.execute('SELECT COUNT(*) FROM tracks')
        tracks_tracked = cursor.fetchone()[0]

        cursor.execute("""
            WITH latest AS (
                SELECT track_id FROM snapshots
                WHERE time_range = 'short_term'
                  AND snapshot_date = (
                      SELECT MAX(snapshot_date) FROM snapshots
                      WHERE time_range = 'short_term'
                  )
            ),
            prev AS (
                SELECT track_id FROM snapshots
                WHERE time_range = 'short_term'
                  AND snapshot_date = (
                      SELECT MAX(snapshot_date) FROM snapshots
                      WHERE time_range = 'short_term'
                        AND snapshot_date < (
                            SELECT MAX(snapshot_date) FROM snapshots
                            WHERE time_range = 'short_term'
                        )
                  )
            )
            SELECT
                COUNT(*) AS latest_size,
                SUM(CASE WHEN p.track_id IS NULL THEN 1 ELSE 0 END) AS new_count
            FROM latest l LEFT JOIN prev p ON p.track_id = l.track_id
        """)
        row = cursor.fetchone()
        latest_size = row['latest_size'] if row else 0
        new_count = row['new_count'] if row else 0

        cursor.execute("""
            SELECT COUNT(DISTINCT snapshot_date) FROM snapshots
            WHERE time_range = 'short_term'
        """)
        total_snapshots = cursor.fetchone()[0]

        if total_snapshots < 2 or latest_size == 0:
            turnover_pct = None
            turnover_subtext = 'Take another snapshot to see turnover.'
        else:
            turnover_pct = round(100.0 * new_count / latest_size)
            turnover_subtext = 'of latest snapshot is new'

        cursor.execute("""
            SELECT t.artist_name, COUNT(DISTINCT s.snapshot_date) AS appearances
            FROM tracks t JOIN snapshots s ON s.track_id = t.track_id
            WHERE s.time_range = 'short_term'
            GROUP BY t.artist_name
            ORDER BY appearances DESC
            LIMIT 1
        """)
        artist_row = cursor.fetchone()
        if artist_row and total_snapshots > 0:
            consistent_artist = artist_row['artist_name']
            consistent_subtext = (
                f"in {artist_row['appearances']} of {total_snapshots} snapshots"
            )
        else:
            consistent_artist = None
            consistent_subtext = 'Take a snapshot to surface your anchor artist.'

        return {
            'tracks_tracked': tracks_tracked,
            'turnover_pct': turnover_pct,
            'turnover_subtext': turnover_subtext,
            'consistent_artist': consistent_artist,
            'consistent_subtext': consistent_subtext,
        }
    except sqlite3.Error as e:
        print(f"Database query error: {e}")
        return empty
    finally:
        conn.close()


def get_taste_chart_data():
    empty = {'mode': 'empty', 'labels': [], 'datasets': []}
    conn = get_db_connection()
    if not conn:
        return empty
    try:
        cursor = conn.cursor()
        cursor.execute("""
            SELECT strftime('%Y-%m', s.snapshot_date) AS month, COUNT(*) AS discoveries
            FROM snapshots s
            WHERE s.time_range = 'short_term'
              AND NOT EXISTS (
                  SELECT 1 FROM snapshots earlier
                  WHERE earlier.track_id = s.track_id
                    AND earlier.snapshot_date < s.snapshot_date
              )
            GROUP BY month ORDER BY month
        """)
        rows = cursor.fetchall()
        return {
            'mode': 'discoveries',
            'labels': [_format_month(r['month']) for r in rows],
            'datasets': [
                {'label': 'New tracks', 'data': [r['discoveries'] for r in rows]},
            ],
        }
    except sqlite3.Error as e:
        print(f"Chart data query error: {e}")
        return empty
    finally:
        conn.close()


# ---------------------------------------------------------------------------
# Auth: decorator + token refresh + the three OAuth routes
# ---------------------------------------------------------------------------

def require_auth(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'access_token' not in session:
            session['next_url'] = request.url
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function


@app.before_request
def refresh_token_if_needed():
    if 'access_token' not in session or 'refresh_token' not in session:
        return
    if time.time() < session.get('token_expires_at', 0) - 300:
        return
    try:
        spotify = SpotifyClient()
        new_tokens = spotify.refresh_access_token(session['refresh_token'])
    except Exception as e:
        print(f"Token refresh failed: {e}")
        for key in ('access_token', 'refresh_token', 'token_expires_at',
                    'oauth_state', 'next_url'):
            session.pop(key, None)
        flash('Your Spotify session expired. Please log in again.', 'warning')
        return redirect(url_for('login'))

    session['access_token'] = new_tokens['access_token']
    if new_tokens.get('refresh_token'):
        session['refresh_token'] = new_tokens['refresh_token']
    session['token_expires_at'] = time.time() + new_tokens['expires_in']


@app.route('/login')
def login():
    state = secrets.token_urlsafe(32)
    session['oauth_state'] = state
    spotify = SpotifyClient()
    auth_url = spotify.get_authorization_url(
        redirect_uri=SPOTIFY_REDIRECT_URI,
        scope='user-top-read playlist-modify-public playlist-modify-private',
        state=state,
    )
    return redirect(auth_url)


@app.route('/callback')
def callback():
    expected_state = session.pop('oauth_state', None)
    received_state = request.args.get('state')
    if not expected_state or received_state != expected_state:
        flash('Authentication failed: state mismatch. Please try again.', 'error')
        return redirect(url_for('login'))
    code = request.args.get('code')
    if not code:
        flash('Authentication failed: no authorization code returned.', 'error')
        return redirect(url_for('login'))
    try:
        spotify = SpotifyClient()
        token_data = spotify.exchange_code_for_token(
            code=code,
            redirect_uri=SPOTIFY_REDIRECT_URI,
        )
    except Exception as e:
        print(f"Token exchange failed: {e}")
        flash('Authentication failed during token exchange.', 'error')
        return redirect(url_for('login'))
    session['access_token'] = token_data['access_token']
    session['refresh_token'] = token_data.get('refresh_token')
    session['token_expires_at'] = time.time() + token_data['expires_in']
    next_url = session.pop('next_url', url_for('home'))
    return redirect(next_url)


@app.route('/logout')
def logout():
    session.clear()
    return redirect(url_for('home'))


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------

@app.route('/')
@require_auth
def home():
    stats = calculate_taste_stats()
    chart_data = get_taste_chart_data()
    return render_template('home.html', stats=stats, chart_data=chart_data)


# Stubs for the routes later sections fill in. Defining them now keeps
# url_for('analytics') / url_for('playlists') / url_for('settings') in templates from raising
# a BuildError before those sections land.
@app.route('/analytics')
@require_auth
def analytics():
    return "Analytics page coming in a later section", 200


@app.route('/playlists')
@require_auth
def playlists():
    return "Playlist Manager coming in a later section", 200


@app.route('/settings')
@require_auth
def settings():
    return "Settings page coming in a later section", 200


# ---------------------------------------------------------------------------
# Error handlers
# ---------------------------------------------------------------------------

@app.errorhandler(404)
def page_not_found(error):
    return render_template('404.html'), 404


@app.errorhandler(500)
def internal_error(error):
    return render_template('500.html'), 500


if __name__ == '__main__':
    app.run(debug=True)

The three placeholder routes keep the navigation links working while you are building the app page by page. Replace each placeholder when the later section gives you the real route.

What to check before moving on

  • music_time_machine.db is in the project root.
  • spotify_client.py exists beside app.py.
  • SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, and SECRET_KEY are available as environment variables or in a local .env file.
  • SPOTIFY_REDIRECT_URI matches the callback route in this Flask app.
  • Protected pages redirect to /login when the user is not connected.
  • The dashboard still renders when the database has little or no data.

The app now has the two connections it needs: SQLite for saved listening history, and Spotify OAuth for the user's live account.