Skip to content

Commit b7c9605

Browse files
committed
feat: add mood-based ambient playlist mini-player (#1404)
- Backend: new ambient_music mapper maps parsed mood queries to curated open Spotify playlists and includes an ambient_music object in the /api/v1/mood-search response. - Frontend: glassmorphic floating music panel on the discovery page that appears after a mood search, updates its Spotify embed src per mood, and coexists with the Ambient Sanctuary SFX panel (overlay behavior). - Tests: unit tests for the mapper plus an endpoint check asserting the ambient_music payload shape. Fixes #1404
1 parent af173a5 commit b7c9605

8 files changed

Lines changed: 263 additions & 7 deletions

File tree

‎backend/app.py‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,7 +1104,7 @@ def handle_mood_search(validated_data):
11041104
# Note: CORS preflight (OPTIONS) is handled globally by Flask-CORS —
11051105
# no manual OPTIONS check needed here.
11061106

1107-
from exceptions import (
1107+
from backend.core.exceptions.exceptions import (
11081108
LLMCircuitBreakerOpenError, AIServiceException,
11091109
ValidationException, InvalidInputError
11101110
)
@@ -1117,19 +1117,26 @@ def handle_mood_search(validated_data):
11171117
# Try to use enhanced mood parsing if available
11181118
try:
11191119
from mood_analysis.mood_query_parser import parse_mood_query, get_recommendation_prompt
1120+
from mood_analysis.ambient_music import get_ambient_music
11201121
parsed_query = parse_mood_query(mood_query)
11211122
enhanced_prompt = get_recommendation_prompt(mood_query)
1122-
1123+
11231124
logger.info(f"Parsed mood query: {parsed_query.to_dict()}")
1124-
1125+
1126+
# Map the parsed mood to a curated ambient playlist for the mini-player
1127+
ambient_music = get_ambient_music(
1128+
mood_query, primary_moods=parsed_query.primary_moods
1129+
)
1130+
11251131
# Use enhanced prompt for recommendations
11261132
recommendations = get_ai_recommendations(enhanced_prompt)
1127-
1133+
11281134
return success_response(
11291135
data={
11301136
"recommendations": recommendations,
11311137
"query": mood_query,
1132-
"parsed_mood": parsed_query.to_dict()
1138+
"parsed_mood": parsed_query.to_dict(),
1139+
"ambient_music": ambient_music
11331140
}
11341141
)
11351142
except ImportError:
@@ -1139,7 +1146,8 @@ def handle_mood_search(validated_data):
11391146
return success_response(
11401147
data={
11411148
"recommendations": recommendations,
1142-
"query": mood_query
1149+
"query": mood_query,
1150+
"ambient_music": {"genre": None, "spotify_embed_url": None}
11431151
}
11441152
)
11451153

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Ambient music mapper: maps a parsed mood query to a curated open
2+
# Spotify playlist. Pure keyword matching on the moods/themes already
3+
# extracted by MoodQueryParser - no extra LLM call, no external API.
4+
5+
from typing import Dict, Optional
6+
7+
# ponytail: static curated list; swap in Spotify API search if curation matters later.
8+
MOOD_PLAYLISTS = [
9+
# (keywords over moods+themes+raw query, genre label, playlist embed URL)
10+
(
11+
{"dark", "mysterious", "gothic", "melancholic"},
12+
"Dark Academia Jazz",
13+
"https://open.spotify.com/embed/playlist/37i9dQZF1DX4sWSpwq3LiO",
14+
),
15+
(
16+
{"cozy"},
17+
"Cozy Reading Cafe",
18+
"https://open.spotify.com/embed/playlist/37i9dQZF1DX4WYpdgoIcn6",
19+
),
20+
(
21+
{"whimsical", "uplifting"},
22+
"Whimsical & Light",
23+
"https://open.spotify.com/embed/playlist/37i9dQZF1DX7EF8wVxBVhG",
24+
),
25+
(
26+
{"atmospheric", "thoughtful"},
27+
"Classical Reading",
28+
"https://open.spotify.com/embed/playlist/37i9dQZF1DX8ymr6UES7vc",
29+
),
30+
]
31+
32+
DEFAULT_PLAYLIST = (
33+
{"genre": "Lo-Fi Reading Chill",
34+
"spotify_embed_url": "https://open.spotify.com/embed/playlist/37i9dQZF1DX889Y07vA7vH"}
35+
)
36+
37+
38+
def get_ambient_music(query: str, primary_moods=None) -> Dict:
39+
"""Return an ambient_music payload for a mood query."""
40+
haystack = set((primary_moods or []))
41+
tokens = {t.strip(".,!?") for t in query.lower().split()}
42+
haystack |= tokens
43+
44+
best: Optional[tuple] = None
45+
best_score = 0
46+
for keywords, genre, url in MOOD_PLAYLISTS:
47+
score = len(keywords & haystack)
48+
if score > best_score:
49+
best_score = score
50+
best = (genre, url)
51+
52+
if best:
53+
return {"genre": best[0], "spotify_embed_url": best[1]}
54+
return dict(DEFAULT_PLAYLIST)
55+
56+
57+
def self_check():
58+
assert get_ambient_music("cozy autumn night with warm tea")["genre"] == "Cozy Reading Cafe"
59+
assert get_ambient_music("dark academia gothic mystery")["genre"] == "Dark Academia Jazz"
60+
assert get_ambient_music("something random")["genre"] == DEFAULT_PLAYLIST["genre"]
61+
r = get_ambient_music("cozy", ["cozy"])
62+
assert r["spotify_embed_url"].startswith("https://open.spotify.com/embed/")
63+
print("ambient_music self-check passed")
64+
65+
66+
if __name__ == "__main__":
67+
self_check()

‎frontend/css/style.css‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6096,6 +6096,42 @@ textarea:focus {
60966096
gap: 10px;
60976097
}
60986098

6099+
/* Mood-based Ambient Music mini-player (#1404) */
6100+
.music-player {
6101+
bottom: 6.1rem !important; /* stacked above the sanctuary leaf button */
6102+
}
6103+
6104+
#musicToggle {
6105+
width: 2.8rem !important;
6106+
height: 2.8rem !important;
6107+
border-radius: 50% !important;
6108+
background: var(--header-bg) !important;
6109+
color: var(--text-main) !important;
6110+
border: 1px solid var(--glass-border) !important;
6111+
display: flex !important;
6112+
align-items: center !important;
6113+
justify-content: center !important;
6114+
cursor: pointer !important;
6115+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
6116+
transition: all 0.3s ease !important;
6117+
}
6118+
6119+
#musicToggle:hover {
6120+
background: var(--accent-gold) !important;
6121+
color: white !important;
6122+
transform: scale(1.05);
6123+
}
6124+
6125+
.music-panel {
6126+
width: 280px;
6127+
}
6128+
6129+
.music-panel .music-empty {
6130+
color: var(--text-muted);
6131+
font-size: 0.85rem;
6132+
margin: 0;
6133+
}
6134+
60996135
.ambient-option span {
61006136
display: flex;
61016137
align-items: center;

‎frontend/js/ambient-music.js‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Mood-based Ambient Music mini-player for BiblioDrift.
3+
* Listens for mood-search responses (window event) and swaps the Spotify
4+
* embed inside a glassmorphic floating panel, styled after Ambient Sanctuary.
5+
* Hidden until the first search provides an ambient_music payload.
6+
*/
7+
8+
class AmbientMusicPlayer {
9+
constructor() {
10+
this.root = document.getElementById('musicPlayer');
11+
this.toggleBtn = document.getElementById('musicToggle');
12+
this.panel = document.getElementById('musicPanel');
13+
this.embed = document.getElementById('musicEmbed');
14+
this.genreLabel = document.getElementById('musicGenre');
15+
this.emptyMsg = document.getElementById('musicEmpty');
16+
17+
if (!this.root || !this.toggleBtn || !this.panel || !this.embed) return;
18+
19+
this.toggleBtn.setAttribute('aria-controls', 'musicPanel');
20+
this.currentUrl = null;
21+
22+
this.toggleBtn.addEventListener('click', (e) => {
23+
e.stopPropagation();
24+
const isActive = this.panel.classList.toggle('active');
25+
this.toggleBtn.classList.toggle('active', isActive);
26+
this.toggleBtn.setAttribute('aria-expanded', isActive ? 'true' : 'false');
27+
});
28+
29+
document.addEventListener('click', (e) => {
30+
if (!this.panel.contains(e.target) && e.target !== this.toggleBtn && !this.toggleBtn.contains(e.target)) {
31+
this.panel.classList.remove('active');
32+
this.toggleBtn.classList.remove('active');
33+
this.toggleBtn.setAttribute('aria-expanded', 'false');
34+
}
35+
});
36+
37+
// app.js dispatches this after every mood-search API response
38+
window.addEventListener('bibliodrift:ambient-music', (e) => this.update(e.detail));
39+
}
40+
41+
update(ambientMusic) {
42+
if (!ambientMusic || !ambientMusic.spotify_embed_url) return;
43+
if (ambientMusic.spotify_embed_url === this.currentUrl) return;
44+
this.currentUrl = ambientMusic.spotify_embed_url;
45+
this.embed.src = `${ambientMusic.spotify_embed_url}?utm_source=generator&theme=0`;
46+
this.embed.style.display = 'block';
47+
if (this.emptyMsg) this.emptyMsg.style.display = 'none';
48+
if (this.genreLabel && ambientMusic.genre) {
49+
this.genreLabel.textContent = `Now vibing: ${ambientMusic.genre}`;
50+
}
51+
this.root.hidden = false;
52+
}
53+
}
54+
55+
document.addEventListener('DOMContentLoaded', () => {
56+
window.ambientMusicPlayer = new AmbientMusicPlayer();
57+
});

‎frontend/js/app.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3279,6 +3279,17 @@ document.addEventListener('DOMContentLoaded', async () => {
32793279
});
32803280

32813281
renderer.renderCuratedSection(query, 'search-results-grid', 20);
3282+
3283+
// Fire-and-forget: fetch a matching ambient playlist for this mood query
3284+
fetch(`${MOOD_API_BASE}/mood-search`, {
3285+
method: 'POST',
3286+
headers: { 'Content-Type': 'application/json' },
3287+
body: JSON.stringify({ query })
3288+
}).then(res => res.ok ? res.json() : null).then(data => {
3289+
window.dispatchEvent(new CustomEvent('bibliodrift:ambient-music', {
3290+
detail: data?.data?.ambient_music || null
3291+
}));
3292+
}).catch(() => { /* playlist is optional; search results still render */ });
32823293
} else if (document.getElementById('dynamic-shelves-container')) {
32833294
console.log('📚 Initializing Curated Discovery Sections...');
32843295
const container = document.getElementById('dynamic-shelves-container');

‎frontend/js/chat.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,10 @@ Tell me: what is stirring in you today?`,
332332
if (moodResponse.ok) {
333333
const moodData = await moodResponse.json();
334334
if (moodData.success) {
335+
// Surface the ambient music payload for the mini-player
336+
window.dispatchEvent(new CustomEvent('bibliodrift:ambient-music', {
337+
detail: moodData.data?.ambient_music || null
338+
}));
335339
const books = await this.searchGoogleBooks(userMessage);
336340
return {
337341
message: this.generateContextualResponse(userMessage, books),

‎frontend/pages/app.html‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,20 @@ <h3>Your Reading Mood Tags</h3>
424424
</button>
425425

426426
<!-- Ambient Sanctuary UI -->
427+
<div class="ambient-sanctuary music-player" id="musicPlayer" hidden>
428+
<button id="musicToggle" title="Ambient Music" aria-expanded="false" aria-controls="musicPanel">
429+
<i class="fa-solid fa-music"></i>
430+
</button>
431+
432+
<div id="musicPanel" class="ambient-panel music-panel">
433+
<h3 style="color: var(--text-main) !important;" id="musicGenre">Ambient Music</h3>
434+
<iframe id="musicEmbed" src="" width="100%" height="80" frameborder="0"
435+
allowtransparency="true" allow="encrypted-media" title="Ambient playlist player"
436+
style="border-radius: 12px; display: none;"></iframe>
437+
<p id="musicEmpty" class="music-empty">Search for a mood to get a matching playlist.</p>
438+
</div>
439+
</div>
440+
427441
<div class="ambient-sanctuary">
428442
<button id="ambientToggle" title="Ambient Sanctuary" aria-expanded="false" aria-controls="ambientPanel">
429443
<i class="fa-solid fa-leaf"></i>
@@ -515,9 +529,9 @@ <h3 style="color: var(--text-main) !important;">Ambient Sanctuary</h3>
515529
</div>
516530

517531
<script src="../js/ambient.js"></script>
532+
<script src="../js/ambient-music.js"></script>
518533
<script src="../js/security.js"></script>
519534
<script src="../js/pwa.js"></script>
520-
<!-- AI Chatbot Widget -->
521535
<div id="ai-chatbot-widget" class="chatbot-widget">
522536
<button id="chatbot-toggle-btn" class="chatbot-toggle-btn" title="Chat with Elara">
523537
<i class="fa-solid fa-robot"></i>

‎tests/test_ambient_music.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Tests for ambient music mapping (#1404): unit tests for the mapper and
2+
an endpoint check that /api/v1/mood-search includes an ambient_music payload."""
3+
4+
# Load the mapper module directly: importing the mood_analysis package pulls in
5+
# heavy NLP deps that conftest deliberately stubs out for the test environment.
6+
import importlib.util
7+
import os
8+
9+
_SPEC = importlib.util.spec_from_file_location(
10+
"ambient_music",
11+
os.path.join(
12+
os.path.dirname(__file__), "..", "backend", "mood_analysis", "ambient_music.py"
13+
),
14+
)
15+
_ambient_music = importlib.util.module_from_spec(_SPEC)
16+
_SPEC.loader.exec_module(_ambient_music)
17+
18+
get_ambient_music = _ambient_music.get_ambient_music
19+
DEFAULT_PLAYLIST = _ambient_music.DEFAULT_PLAYLIST
20+
self_check = _ambient_music.self_check
21+
22+
23+
def test_cozy_query_maps_to_cozy_playlist():
24+
result = get_ambient_music("cozy autumn night with warm tea")
25+
assert result["genre"] == "Cozy Reading Cafe"
26+
assert result["spotify_embed_url"].startswith("https://open.spotify.com/embed/")
27+
28+
29+
def test_dark_query_maps_to_dark_academia():
30+
result = get_ambient_music("dark academia gothic mystery", primary_moods=["dark"])
31+
assert result["genre"] == "Dark Academia Jazz"
32+
33+
34+
def test_unknown_query_falls_back_to_default():
35+
result = get_ambient_music("zzz qqq xyz")
36+
assert result == DEFAULT_PLAYLIST
37+
38+
39+
def test_self_check():
40+
self_check()
41+
42+
43+
def test_mood_search_includes_ambient_music(client):
44+
"""Endpoint returns ambient_music in data (AI recommendations are mocked
45+
away by patching; we only assert on the new field's presence/shape)."""
46+
from unittest.mock import patch
47+
from flask import url_for
48+
49+
client.application.config["WTF_CSRF_ENABLED"] = False
50+
with patch("backend.app.get_ai_recommendations", return_value=["book1"]):
51+
resp = client.post(
52+
"/api/v1/mood-search",
53+
json={"query": "cozy mystery"},
54+
)
55+
assert resp.status_code == 200
56+
body = resp.get_json()
57+
# success_response flattens the data dict into the top-level JSON body
58+
assert "ambient_music" in body
59+
assert set(body["ambient_music"].keys()) == {"genre", "spotify_embed_url"}

0 commit comments

Comments
 (0)