Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1104,7 +1104,7 @@ def handle_mood_search(validated_data):
# Note: CORS preflight (OPTIONS) is handled globally by Flask-CORS —
# no manual OPTIONS check needed here.

from exceptions import (
from backend.core.exceptions.exceptions import (
LLMCircuitBreakerOpenError, AIServiceException,
ValidationException, InvalidInputError
)
Expand All @@ -1117,19 +1117,26 @@ def handle_mood_search(validated_data):
# Try to use enhanced mood parsing if available
try:
from mood_analysis.mood_query_parser import parse_mood_query, get_recommendation_prompt
from mood_analysis.ambient_music import get_ambient_music
parsed_query = parse_mood_query(mood_query)
enhanced_prompt = get_recommendation_prompt(mood_query)

logger.info(f"Parsed mood query: {parsed_query.to_dict()}")


# Map the parsed mood to a curated ambient playlist for the mini-player
ambient_music = get_ambient_music(
mood_query, primary_moods=parsed_query.primary_moods
)

# Use enhanced prompt for recommendations
recommendations = get_ai_recommendations(enhanced_prompt)

return success_response(
data={
"recommendations": recommendations,
"query": mood_query,
"parsed_mood": parsed_query.to_dict()
"parsed_mood": parsed_query.to_dict(),
"ambient_music": ambient_music
}
)
except ImportError:
Expand All @@ -1139,7 +1146,8 @@ def handle_mood_search(validated_data):
return success_response(
data={
"recommendations": recommendations,
"query": mood_query
"query": mood_query,
"ambient_music": {"genre": None, "spotify_embed_url": None}
}
)

Expand Down
67 changes: 67 additions & 0 deletions backend/mood_analysis/ambient_music.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Ambient music mapper: maps a parsed mood query to a curated open
# Spotify playlist. Pure keyword matching on the moods/themes already
# extracted by MoodQueryParser - no extra LLM call, no external API.

from typing import Dict, Optional

# ponytail: static curated list; swap in Spotify API search if curation matters later.
MOOD_PLAYLISTS = [
# (keywords over moods+themes+raw query, genre label, playlist embed URL)
(
{"dark", "mysterious", "gothic", "melancholic"},
"Dark Academia Jazz",
"https://open.spotify.com/embed/playlist/37i9dQZF1DX4sWSpwq3LiO",
),
(
{"cozy"},
"Cozy Reading Cafe",
"https://open.spotify.com/embed/playlist/37i9dQZF1DX4WYpdgoIcn6",
),
(
{"whimsical", "uplifting"},
"Whimsical & Light",
"https://open.spotify.com/embed/playlist/37i9dQZF1DX7EF8wVxBVhG",
),
(
{"atmospheric", "thoughtful"},
"Classical Reading",
"https://open.spotify.com/embed/playlist/37i9dQZF1DX8ymr6UES7vc",
),
]

DEFAULT_PLAYLIST = (
{"genre": "Lo-Fi Reading Chill",
"spotify_embed_url": "https://open.spotify.com/embed/playlist/37i9dQZF1DX889Y07vA7vH"}
)


def get_ambient_music(query: str, primary_moods=None) -> Dict:
"""Return an ambient_music payload for a mood query."""
haystack = set((primary_moods or []))
tokens = {t.strip(".,!?") for t in query.lower().split()}
haystack |= tokens

best: Optional[tuple] = None
best_score = 0
for keywords, genre, url in MOOD_PLAYLISTS:
score = len(keywords & haystack)
if score > best_score:
best_score = score
best = (genre, url)

if best:
return {"genre": best[0], "spotify_embed_url": best[1]}
return dict(DEFAULT_PLAYLIST)


def self_check():
assert get_ambient_music("cozy autumn night with warm tea")["genre"] == "Cozy Reading Cafe"
assert get_ambient_music("dark academia gothic mystery")["genre"] == "Dark Academia Jazz"
assert get_ambient_music("something random")["genre"] == DEFAULT_PLAYLIST["genre"]
r = get_ambient_music("cozy", ["cozy"])
assert r["spotify_embed_url"].startswith("https://open.spotify.com/embed/")
print("ambient_music self-check passed")


if __name__ == "__main__":
self_check()
36 changes: 36 additions & 0 deletions frontend/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -6096,6 +6096,42 @@ textarea:focus {
gap: 10px;
}

/* Mood-based Ambient Music mini-player (#1404) */
.music-player {
bottom: 6.1rem !important; /* stacked above the sanctuary leaf button */
}

#musicToggle {
width: 2.8rem !important;
height: 2.8rem !important;
border-radius: 50% !important;
background: var(--header-bg) !important;
color: var(--text-main) !important;
border: 1px solid var(--glass-border) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
transition: all 0.3s ease !important;
}

#musicToggle:hover {
background: var(--accent-gold) !important;
color: white !important;
transform: scale(1.05);
}

.music-panel {
width: 280px;
}

.music-panel .music-empty {
color: var(--text-muted);
font-size: 0.85rem;
margin: 0;
}

.ambient-option span {
display: flex;
align-items: center;
Expand Down
57 changes: 57 additions & 0 deletions frontend/js/ambient-music.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Mood-based Ambient Music mini-player for BiblioDrift.
* Listens for mood-search responses (window event) and swaps the Spotify
* embed inside a glassmorphic floating panel, styled after Ambient Sanctuary.
* Hidden until the first search provides an ambient_music payload.
*/

class AmbientMusicPlayer {
constructor() {
this.root = document.getElementById('musicPlayer');
this.toggleBtn = document.getElementById('musicToggle');
this.panel = document.getElementById('musicPanel');
this.embed = document.getElementById('musicEmbed');
this.genreLabel = document.getElementById('musicGenre');
this.emptyMsg = document.getElementById('musicEmpty');

if (!this.root || !this.toggleBtn || !this.panel || !this.embed) return;

this.toggleBtn.setAttribute('aria-controls', 'musicPanel');
this.currentUrl = null;

this.toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isActive = this.panel.classList.toggle('active');
this.toggleBtn.classList.toggle('active', isActive);
this.toggleBtn.setAttribute('aria-expanded', isActive ? 'true' : 'false');
});

document.addEventListener('click', (e) => {
if (!this.panel.contains(e.target) && e.target !== this.toggleBtn && !this.toggleBtn.contains(e.target)) {
this.panel.classList.remove('active');
this.toggleBtn.classList.remove('active');
this.toggleBtn.setAttribute('aria-expanded', 'false');
}
});

// app.js dispatches this after every mood-search API response
window.addEventListener('bibliodrift:ambient-music', (e) => this.update(e.detail));
}

update(ambientMusic) {
if (!ambientMusic || !ambientMusic.spotify_embed_url) return;
if (ambientMusic.spotify_embed_url === this.currentUrl) return;
this.currentUrl = ambientMusic.spotify_embed_url;
this.embed.src = `${ambientMusic.spotify_embed_url}?utm_source=generator&theme=0`;
this.embed.style.display = 'block';
if (this.emptyMsg) this.emptyMsg.style.display = 'none';
if (this.genreLabel && ambientMusic.genre) {
this.genreLabel.textContent = `Now vibing: ${ambientMusic.genre}`;
}
this.root.hidden = false;
}
}

document.addEventListener('DOMContentLoaded', () => {
window.ambientMusicPlayer = new AmbientMusicPlayer();
});
11 changes: 11 additions & 0 deletions frontend/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3279,6 +3279,17 @@ document.addEventListener('DOMContentLoaded', async () => {
});

renderer.renderCuratedSection(query, 'search-results-grid', 20);

// Fire-and-forget: fetch a matching ambient playlist for this mood query
fetch(`${MOOD_API_BASE}/mood-search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
}).then(res => res.ok ? res.json() : null).then(data => {
window.dispatchEvent(new CustomEvent('bibliodrift:ambient-music', {
detail: data?.data?.ambient_music || null
}));
}).catch(() => { /* playlist is optional; search results still render */ });
} else if (document.getElementById('dynamic-shelves-container')) {
console.log('📚 Initializing Curated Discovery Sections...');
const container = document.getElementById('dynamic-shelves-container');
Expand Down
4 changes: 4 additions & 0 deletions frontend/js/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,10 @@ Tell me: what is stirring in you today?`,
if (moodResponse.ok) {
const moodData = await moodResponse.json();
if (moodData.success) {
// Surface the ambient music payload for the mini-player
window.dispatchEvent(new CustomEvent('bibliodrift:ambient-music', {
detail: moodData.data?.ambient_music || null
}));
const books = await this.searchGoogleBooks(userMessage);
return {
message: this.generateContextualResponse(userMessage, books),
Expand Down
16 changes: 15 additions & 1 deletion frontend/pages/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,20 @@ <h3>Your Reading Mood Tags</h3>
</button>

<!-- Ambient Sanctuary UI -->
<div class="ambient-sanctuary music-player" id="musicPlayer" hidden>
<button id="musicToggle" title="Ambient Music" aria-expanded="false" aria-controls="musicPanel">
<i class="fa-solid fa-music"></i>
</button>

<div id="musicPanel" class="ambient-panel music-panel">
<h3 style="color: var(--text-main) !important;" id="musicGenre">Ambient Music</h3>
<iframe id="musicEmbed" src="" width="100%" height="80" frameborder="0"
allowtransparency="true" allow="encrypted-media" title="Ambient playlist player"
style="border-radius: 12px; display: none;"></iframe>
<p id="musicEmpty" class="music-empty">Search for a mood to get a matching playlist.</p>
</div>
</div>

<div class="ambient-sanctuary">
<button id="ambientToggle" title="Ambient Sanctuary" aria-expanded="false" aria-controls="ambientPanel">
<i class="fa-solid fa-leaf"></i>
Expand Down Expand Up @@ -515,9 +529,9 @@ <h3 style="color: var(--text-main) !important;">Ambient Sanctuary</h3>
</div>

<script src="../js/ambient.js"></script>
<script src="../js/ambient-music.js"></script>
<script src="../js/security.js"></script>
<script src="../js/pwa.js"></script>
<!-- AI Chatbot Widget -->
<div id="ai-chatbot-widget" class="chatbot-widget">
<button id="chatbot-toggle-btn" class="chatbot-toggle-btn" title="Chat with Elara">
<i class="fa-solid fa-robot"></i>
Expand Down
59 changes: 59 additions & 0 deletions tests/test_ambient_music.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Tests for ambient music mapping (#1404): unit tests for the mapper and
an endpoint check that /api/v1/mood-search includes an ambient_music payload."""

# Load the mapper module directly: importing the mood_analysis package pulls in
# heavy NLP deps that conftest deliberately stubs out for the test environment.
import importlib.util
import os

_SPEC = importlib.util.spec_from_file_location(
"ambient_music",
os.path.join(
os.path.dirname(__file__), "..", "backend", "mood_analysis", "ambient_music.py"
),
)
_ambient_music = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(_ambient_music)

get_ambient_music = _ambient_music.get_ambient_music
DEFAULT_PLAYLIST = _ambient_music.DEFAULT_PLAYLIST
self_check = _ambient_music.self_check


def test_cozy_query_maps_to_cozy_playlist():
result = get_ambient_music("cozy autumn night with warm tea")
assert result["genre"] == "Cozy Reading Cafe"
assert result["spotify_embed_url"].startswith("https://open.spotify.com/embed/")


def test_dark_query_maps_to_dark_academia():
result = get_ambient_music("dark academia gothic mystery", primary_moods=["dark"])
assert result["genre"] == "Dark Academia Jazz"


def test_unknown_query_falls_back_to_default():
result = get_ambient_music("zzz qqq xyz")
assert result == DEFAULT_PLAYLIST


def test_self_check():
self_check()


def test_mood_search_includes_ambient_music(client):
"""Endpoint returns ambient_music in data (AI recommendations are mocked
away by patching; we only assert on the new field's presence/shape)."""
from unittest.mock import patch
from flask import url_for

client.application.config["WTF_CSRF_ENABLED"] = False
with patch("backend.app.get_ai_recommendations", return_value=["book1"]):
resp = client.post(
"/api/v1/mood-search",
json={"query": "cozy mystery"},
)
assert resp.status_code == 200
body = resp.get_json()
# success_response flattens the data dict into the top-level JSON body
assert "ambient_music" in body
assert set(body["ambient_music"].keys()) == {"genre", "spotify_embed_url"}