10. Upgrading the Playlist Manager to AJAX
The form-based playlist generator works, but it forces a full page reload on every submit. AJAX upgrades the same feature: a JavaScript layer intercepts the form submit, posts JSON to a new API endpoint, and updates the page in place with a loading state and a result card. Same SQL, same Spotify calls, different transport.
Upgrading to AJAX for dynamic generation
Modern readers expect instant feedback without page refreshes. AJAX (Asynchronous JavaScript and XML, though in practice we send JSON) lets you submit a form in the background, show a loading indicator, and display the result without navigating away. You'll add a JavaScript layer that intercepts the form submit, posts to a new API endpoint, and updates the DOM with the result.
First, create a new API endpoint that returns JSON instead of rendering HTML. This endpoint handles AJAX requests only; the form-based /playlists route from the previous page stays intact. Add this route to app.py:
# app.py -- AJAX endpoint for playlist generation.
# Reuses PLAYLIST_SOURCES and _build_playlist_query from the form-based route.
import requests
from flask import jsonify
@app.route('/api/generate-playlist', methods=['POST'])
@require_auth
def api_generate_playlist():
"""JSON endpoint for the AJAX form. Same query path as /playlists."""
data = request.get_json(silent=True)
if not data:
return jsonify({'error': 'No JSON body provided'}), 400
source = (data.get('source') or '').strip().lower()
custom_name = (data.get('playlist_name') or '').strip()
if source not in PLAYLIST_SOURCES:
return jsonify({'error': 'Invalid playlist source'}), 400
if custom_name and len(custom_name) > 100:
custom_name = custom_name[:100]
source_config = PLAYLIST_SOURCES[source]
if not custom_name:
custom_name = (
f"{source_config['default_name']} - "
f"{datetime.now().strftime('%B %Y')}"
)
try:
sql, params = _build_playlist_query(source)
conn = get_db_connection()
if not conn:
return jsonify({'error': 'Database unavailable'}), 503
try:
cursor = conn.cursor()
cursor.execute(sql, params)
tracks = cursor.fetchall()
finally:
conn.close()
if not tracks:
return jsonify({
'error': (
f"No tracks found for {source_config['label']} yet. "
f"Run a Chapter 16 snapshot, then try again."
)
}), 404
track_ids = [row['track_id'] for row in tracks]
access_token = session.get('access_token')
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
}
user_resp = requests.get(
'https://api.spotify.com/v1/me', headers=headers, timeout=10,
)
user_resp.raise_for_status()
user_id = user_resp.json()['id']
create_resp = requests.post(
f'https://api.spotify.com/v1/users/{user_id}/playlists',
headers=headers,
json={
'name': custom_name,
'description': source_config['description'],
'public': False,
},
timeout=10,
)
create_resp.raise_for_status()
playlist = create_resp.json()
playlist_id = playlist['id']
playlist_url = playlist['external_urls']['spotify']
add_resp = requests.post(
f'https://api.spotify.com/v1/playlists/{playlist_id}/tracks',
headers=headers,
json={'uris': [f'spotify:track:{tid}' for tid in track_ids]},
timeout=10,
)
add_resp.raise_for_status()
return jsonify({
'success': True,
'playlist_name': custom_name,
'playlist_url': playlist_url,
'track_count': len(tracks),
'message': f'Playlist "{custom_name}" created with {len(tracks)} tracks.',
})
except requests.exceptions.HTTPError as e:
status = e.response.status_code if e.response is not None else 500
return jsonify({'error': f'Spotify API error: {e}'}), status
except sqlite3.Error as e:
return jsonify({'error': f'Database error: {e}'}), 500
except Exception as e:
print(f"Unexpected error in /api/generate-playlist: {e}")
return jsonify({'error': 'Unexpected server error.'}), 500
This API endpoint mirrors the HTML form route's logic but returns JSON instead of rendering templates. It uses request.get_json() to parse JSON data from the AJAX request body. The response format includes success, playlist_name, playlist_url, and message fields for success, or error field with appropriate HTTP status codes (400 for validation errors, 404 for no data, 500 for server errors) for failures.
Now create static/js/playlist-ajax.js to handle form submission with AJAX:
// Playlist AJAX Handler
// Intercepts form submission for dynamic playlist generation
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('playlistForm');
const generateButton = form.querySelector('button[type="submit"]');
const originalButtonText = generateButton.textContent;
// Extract CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
form.addEventListener('submit', async function(e) {
e.preventDefault(); // Prevent default form submission
// Get form data
const formData = new FormData(form);
const source = formData.get('source');
const playlistName = formData.get('playlist_name');
// Validate source selection
if (!source) {
showError('Please select a playlist source');
return;
}
// Disable button and show loading state
generateButton.disabled = true;
generateButton.textContent = 'Generating...';
// Hide any existing success/error messages
hideMessages();
try {
// Send AJAX request with CSRF token
const response = await fetch('/api/generate-playlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken // Include CSRF token in headers
},
body: JSON.stringify({
source: source,
playlist_name: playlistName
})
});
const data = await response.json();
if (response.ok && data.success) {
// Success - display result
showSuccess(data.playlist_name, data.playlist_url);
form.reset(); // Clear form
} else {
// Error from server
showError(data.error || 'Failed to generate playlist');
}
} catch (error) {
// Network error or JSON parsing error
showError('Network error. Please check your connection and try again.');
console.error('Playlist generation error:', error);
} finally {
// Re-enable button
generateButton.disabled = false;
generateButton.textContent = originalButtonText;
}
});
function showSuccess(playlistName, playlistUrl) {
// Create success card dynamically
const successCard = document.createElement('div');
successCard.className = 'success-card';
successCard.innerHTML = `
Playlist created successfully!
${escapeHtml(playlistName)}
Open in Spotify
The playlist has been added to your Spotify account
`;
// Insert before form
form.parentElement.insertBefore(successCard, form.parentElement.firstChild);
// Scroll to success card
successCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function showError(message) {
// Create error message
const errorDiv = document.createElement('div');
errorDiv.className = 'flash-message flash-error';
errorDiv.textContent = message;
// Insert before form
form.parentElement.insertBefore(errorDiv, form.parentElement.firstChild);
// Auto-remove after 5 seconds
setTimeout(() => errorDiv.remove(), 5000);
}
function hideMessages() {
// Remove any existing success cards or error messages
document.querySelectorAll('.success-card, .flash-message').forEach(el => el.remove());
}
function escapeHtml(text) {
// Prevent XSS attacks in dynamic content
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
});
CSRF token in AJAX requests
The JavaScript extracts the CSRF token from the <meta name="csrf-token"> tag in the shared base layout. This token is then included in every AJAX request via the X-CSRFToken header. Flask-WTF checks for this header when validating POST requests, just like it checks the hidden form field.
Without this token in the AJAX request headers, Flask-WTF will reject the request with a 400 Bad Request error. The meta tag approach is cleaner than extracting the token from the hidden form input because it's accessible throughout the page's JavaScript and doesn't require DOM manipulation of the form itself.
Debugging AJAX and CSRF issues
If your AJAX request returns a 400 Bad Request error, the CSRF token is almost certainly the cause. Check these in order: (1) Verify the <meta name="csrf-token"> tag exists in the rendered HTML (View Page Source), (2) Confirm the JavaScript successfully extracts the token (add console.log(csrfToken)), (3) Check browser DevTools Network tab to verify the X-CSRFToken header appears in the request, (4) Ensure your SECRET_KEY hasn't changed (changing it invalidates all existing tokens).
The most common mistakes are reading the wrong selector, failing to send the X-CSRFToken header, or changing SECRET_KEY after the page loads. In those cases the meta tag may exist, but Flask-WTF still receives a missing, stale, or invalid token and rejects the request.
Wire the JavaScript into templates/playlists.html by adding a {% block scripts %} after the closing {% endblock %} of the content block. The CSRF meta tag is already in base.html; this snippet only adds the script tag.
{% block scripts %}
<!-- AJAX Enhancement -->
<script src="{{ url_for('static', filename='js/playlist-ajax.js') }}"></script>
{% endblock %}
Now test the AJAX version. Select a playlist source and submit. The page stays static, the button shows "Generating...", and the success card appears without a page refresh. The HTML form route still works as a fallback if JavaScript is disabled or fails to load.
Progressive enhancement in action
You now have two working implementations: HTML form (reliable, accessible) and AJAX (modern, interactive). The HTML version ensures functionality for all users regardless of JavaScript support. The AJAX version enhances experience for users with modern browsers. This is progressive enhancement: start with baseline functionality, add improvements for capable environments.
To test both versions, disable JavaScript in your browser DevTools settings and submit the form (HTML version triggers). Re-enable JavaScript and submit again (AJAX version triggers). Both produce the same result, but the user experience differs.
Checkpoint: Playlist Manager concepts
Test your understanding of form handling and progressive enhancement.
Why does the playlist generation route validate the source parameter against a whitelist instead of trusting user input?
Answer: Users can modify form data before submission using browser DevTools or by crafting custom HTTP requests. The route validates with source not in PLAYLIST_SOURCES, which checks the submitted value against the dictionary's keys (forgotten_gems, recent_discoveries, current_rotation) before any query lookup runs. Without that check, an attacker could submit source='malicious' and trigger a KeyError in PLAYLIST_SOURCES[source] or reach a query path you never intended to expose. This is defensive programming. Never trust client-side data.
What happens if a user submits the HTML form but has JavaScript enabled? Which version processes the request?
Answer: The AJAX JavaScript intercepts the form submission with e.preventDefault(), preventing the default HTML form behavior. The JavaScript sends a fetch request to /api/generate-playlist instead of navigating to the form's action URL, so the HTML form route never executes. The fallback to standard form submission (the POST /playlists route) only happens when the script fails to load or never attaches its submit handler. Once the handler is attached and e.preventDefault() has fired, an error thrown later inside the handler does not revert to a native form POST; you have to catch those errors in the fetch logic itself.
Why does the AJAX endpoint return JSON with HTTP status codes (400, 404, 500) instead of always returning 200 with an error field?
Answer: HTTP status codes communicate result types to clients, middleware, and logging systems. Status 400 indicates client errors (invalid input), 404 means resource not found (no tracks), 500 signals server errors (exceptions). This lets JavaScript check response.ok to determine success without parsing JSON first. It also enables proper error logging, cache behavior (browsers won't cache 4xx/5xx responses), and API monitoring. Always returning 200 with error fields is an anti-pattern that hides failures from infrastructure tools.
The AJAX success handler uses escapeHtml() before inserting playlist names into the DOM. What attack does this prevent?
Answer: XSS (Cross-Site Scripting) attacks where malicious users inject JavaScript code through form inputs. If a user provides a playlist name like <script>alert('hacked')</script> and you insert it directly with innerHTML, the browser executes the script. The escapeHtml() function converts special characters (< becomes <, > becomes >) so the browser renders them as text instead of parsing them as HTML tags. This is critical whenever inserting user-provided data into the DOM.