diff --git a/backend/app.py b/backend/app.py index 0a89b85c7..37a848629 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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 ) @@ -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: @@ -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} } ) diff --git a/backend/mood_analysis/ambient_music.py b/backend/mood_analysis/ambient_music.py new file mode 100644 index 000000000..e0c363509 --- /dev/null +++ b/backend/mood_analysis/ambient_music.py @@ -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() diff --git a/frontend/css/style.css b/frontend/css/style.css index 47630fbda..2952caf55 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -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; diff --git a/frontend/js/ambient-music.js b/frontend/js/ambient-music.js new file mode 100644 index 000000000..defec8a7c --- /dev/null +++ b/frontend/js/ambient-music.js @@ -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(); +}); diff --git a/frontend/js/app.js b/frontend/js/app.js index 36a0fdefa..522e83e96 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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'); diff --git a/frontend/js/chat.js b/frontend/js/chat.js index 8aefd0bce..a09f80c5a 100644 --- a/frontend/js/chat.js +++ b/frontend/js/chat.js @@ -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), diff --git a/frontend/pages/app.html b/frontend/pages/app.html index 816797e97..d0caace03 100644 --- a/frontend/pages/app.html +++ b/frontend/pages/app.html @@ -424,6 +424,20 @@
Search for a mood to get a matching playlist.
+