feat: implement AI-curated playlist service and dashboard integration

- Added hierarchical AGENTS.md knowledge base
- Implemented PlaylistService with 6h themed and 24h devotion mix logic
- Integrated AI theme generation for 6h playlists via Gemini/OpenAI
- Added /playlists/refresh and metadata endpoints to API
- Updated background worker with scheduled playlist curation
- Created frontend PlaylistsSection, Tooltip components and integrated into Dashboard
- Added Alembic migration for playlist tracking columns
- Fixed Docker healthcheck with curl installation
This commit is contained in:
bnair123
2025-12-30 09:45:19 +04:00
parent fa28b98c1a
commit 93e7c13f3d
18 changed files with 1037 additions and 295 deletions

View File

@@ -0,0 +1,34 @@
# FRONTEND COMPONENTS KNOWLEDGE BASE
**Directory:** `frontend/src/components`
## OVERVIEW
This directory contains the primary UI components for the MusicAnalyser dashboard. The architecture follows a **Presentational & Container pattern**, where `Dashboard.jsx` acts as the main container orchestrating data fetching and state, while sub-components handle specific visualizations and data displays.
The UI is built with **React (Vite)**, utilizing **Tailwind CSS** for custom layouts/styling and **Ant Design** for basic UI primitives. Data visualization is powered by **Recharts** and custom SVG/Tailwind grid implementations.
## WHERE TO LOOK
| Component | Role | Complexity |
|-----------|------|------------|
| `Dashboard.jsx` | Main entry point. Handles API interaction (`/api/snapshots`), data caching (`localStorage`), and layout. | High |
| `VibeRadar.jsx` | Uses `Recharts` RadarChart to visualize "Sonic DNA" (acousticness, energy, valence, etc.). | High |
| `HeatMap.jsx` | Custom grid implementation for "Chronobiology" (listening density across days/time blocks). | Medium |
| `StatsGrid.jsx` | Renders high-level metrics (Minutes Listened, "Obsession" Track, Hipster Score) in a responsive grid. | Medium |
| `ListeningLog.jsx` | Displays a detailed list of recently played tracks. | Low |
| `NarrativeSection.jsx` | Renders AI-generated narratives, "vibe checks", and "roasts". | Low |
| `TopRotation.jsx` | Displays top artists and tracks with counts and popularity bars. | Medium |
## CONVENTIONS
- **Styling**: Leverages Tailwind utility classes.
- **Key Colors**: `primary` (#256af4), `card-dark` (#1e293b), `card-darker` (#0f172a).
- **Glassmorphism**: Use `glass-panel` for semi-transparent headers and panels.
- **Icons**: Standardized on **Google Material Symbols** (`material-symbols-outlined`).
- **Data Flow**: Unidirectional. `Dashboard.jsx` fetches data and passes specific slices down to sub-components via props.
- **Caching**: API responses are cached in `localStorage` with a date-based key (`sonicstats_v2_YYYY-MM-DD`) to minimize redundant requests.
- **Visualizations**:
- Use `Recharts` for standard charts (Radar, Line).
- Use Tailwind grid and relative/absolute positioning for custom visualizations (HeatMap, Mood Clusters).
- **Responsiveness**: Use responsive grid prefixes (`grid-cols-1 md:grid-cols-2 lg:grid-cols-4`) to ensure dashboard works across devices.

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import axios from 'axios';
import NarrativeSection from './NarrativeSection';
import StatsGrid from './StatsGrid';
import PlaylistsSection from './PlaylistsSection';
import VibeRadar from './VibeRadar';
import HeatMap from './HeatMap';
import TopRotation from './TopRotation';
@@ -105,6 +106,8 @@ const Dashboard = () => {
<StatsGrid metrics={data?.metrics} />
<PlaylistsSection />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-8">
<VibeRadar vibe={data?.metrics?.vibe} />

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { Card, Button, Typography, Space, Spin, message, Tooltip as AntTooltip } from 'antd';
import {
PlayCircleOutlined,
ReloadOutlined,
HistoryOutlined,
InfoCircleOutlined,
CustomerServiceOutlined
} from '@ant-design/icons';
import Tooltip from './Tooltip';
const { Title, Text, Paragraph } = Typography;
const PlaylistsSection = () => {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState({ sixHour: false, daily: false });
const [playlists, setPlaylists] = useState(null);
const fetchPlaylists = async () => {
try {
const response = await axios.get('/api/playlists');
setPlaylists(response.data);
} catch (error) {
console.error('Failed to fetch playlists:', error);
message.error('Failed to load playlist metadata');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPlaylists();
}, []);
const handleRefresh = async (type) => {
const isSixHour = type === 'six-hour';
setRefreshing(prev => ({ ...prev, [isSixHour ? 'sixHour' : 'daily']: true }));
try {
const endpoint = isSixHour ? '/api/playlists/refresh/six-hour' : '/api/playlists/refresh/daily';
await axios.post(endpoint);
message.success(`${isSixHour ? '6-Hour' : 'Daily'} playlist refreshed!`);
await fetchPlaylists();
} catch (error) {
console.error(`Refresh failed for ${type}:`, error);
message.error(`Failed to refresh ${type} playlist`);
} finally {
setRefreshing(prev => ({ ...prev, [isSixHour ? 'sixHour' : 'daily']: false }));
}
};
if (loading) return <div className="flex justify-center p-8"><Spin size="large" /></div>;
return (
<div className="mt-8 space-y-6">
<div className="flex items-center space-x-2">
<Title level={3} className="!mb-0 text-white flex items-center">
<CustomerServiceOutlined className="mr-2 text-blue-400" />
AI Curated Playlists
</Title>
<Tooltip text="Dynamic playlists that evolve with your taste. Refreshed automatically, or trigger manually here.">
<InfoCircleOutlined className="text-gray-400 cursor-help" />
</Tooltip>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 6-Hour Playlist */}
<Card
className="bg-slate-800 border-slate-700 shadow-xl"
title={<span className="text-blue-400 flex items-center"><HistoryOutlined className="mr-2" /> Short & Sweet (6h)</span>}
extra={
<Button
type="text"
icon={<ReloadOutlined spin={refreshing.sixHour} />}
onClick={() => handleRefresh('six-hour')}
className="text-gray-400 hover:text-white"
disabled={refreshing.sixHour}
/>
}
>
<div className="space-y-4">
<div>
<Text className="text-gray-400 text-xs uppercase tracking-wider block mb-1">Current Theme</Text>
<Title level={4} className="!mt-0 !mb-1 text-white">{playlists?.six_hour?.theme || 'Calculating...'}</Title>
<Paragraph className="text-gray-300 text-sm italic mb-0">
"{playlists?.six_hour?.reasoning || 'Analyzing your recent listening patterns to find the perfect vibe.'}"
</Paragraph>
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-700">
<div className="flex flex-col">
<Text className="text-gray-500 text-xs">Last Updated</Text>
<Text className="text-gray-300 text-xs font-mono">
{playlists?.six_hour?.last_refresh ? new Date(playlists.six_hour.last_refresh).toLocaleString() : 'Never'}
</Text>
</div>
<Button
type="primary"
shape="round"
icon={<PlayCircleOutlined />}
href={`https://open.spotify.com/playlist/${playlists?.six_hour?.id}`}
target="_blank"
disabled={!playlists?.six_hour?.id}
className="bg-blue-600 hover:bg-blue-500 border-none"
>
Open Spotify
</Button>
</div>
</div>
</Card>
{/* Daily Playlist */}
<Card
className="bg-slate-800 border-slate-700 shadow-xl"
title={<span className="text-purple-400 flex items-center"><PlayCircleOutlined className="mr-2" /> Proof of Commitment (24h)</span>}
extra={
<Button
type="text"
icon={<ReloadOutlined spin={refreshing.daily} />}
onClick={() => handleRefresh('daily')}
className="text-gray-400 hover:text-white"
disabled={refreshing.daily}
/>
}
>
<div className="space-y-4">
<div>
<Text className="text-gray-400 text-xs uppercase tracking-wider block mb-1">Daily Mix Strategy</Text>
<Title level={4} className="!mt-0 !mb-1 text-white">Daily Devotion Mix</Title>
<Paragraph className="text-gray-300 text-sm mb-0">
A blend of 30 all-time favorites and 20 recent discoveries to keep your rotation fresh but familiar.
</Paragraph>
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-700">
<div className="flex flex-col">
<Text className="text-gray-500 text-xs">Last Updated</Text>
<Text className="text-gray-300 text-xs font-mono">
{playlists?.daily?.last_refresh ? new Date(playlists.daily.last_refresh).toLocaleString() : 'Never'}
</Text>
</div>
<Button
type="primary"
shape="round"
icon={<PlayCircleOutlined />}
href={`https://open.spotify.com/playlist/${playlists?.daily?.id}`}
target="_blank"
disabled={!playlists?.daily?.id}
className="bg-purple-600 hover:bg-purple-500 border-none"
>
Open Spotify
</Button>
</div>
</div>
</Card>
</div>
</div>
);
};
export default PlaylistsSection;

View File

@@ -1,4 +1,5 @@
import React from 'react';
import Tooltip from './Tooltip';
const StatsGrid = ({ metrics }) => {
if (!metrics) return null;
@@ -14,8 +15,9 @@ const StatsGrid = ({ metrics }) => {
const uniqueArtists = metrics.volume?.unique_artists || 0;
const hipsterScore = metrics.taste?.hipster_score || 0;
const obscurityRating = metrics.taste?.obscurity_rating || 0;
const concentration = metrics.volume?.concentration?.hhi || 0;
const diversity = metrics.volume?.concentration?.gini || 0;
const peakHour = metrics.time_habits?.peak_hour !== undefined ? `${metrics.time_habits.peak_hour}:00` : "N/A";
return (
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
@@ -56,31 +58,32 @@ const StatsGrid = ({ metrics }) => {
</div>
<div className="flex flex-col gap-4 h-full">
<div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center text-center">
<span className="material-symbols-outlined text-4xl text-primary mb-2">visibility</span>
<div className="text-3xl font-bold text-white">{uniqueArtists}</div>
<div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center text-center group">
<div className="flex items-center gap-2 mb-1">
<div className="text-3xl font-bold text-white">{uniqueArtists}</div>
<Tooltip text="The number of unique artists you've listened to in this period.">
<span className="material-symbols-outlined text-slate-500 text-sm cursor-help">info</span>
</Tooltip>
</div>
<div className="text-slate-400 text-xs uppercase tracking-wider">Unique Artists</div>
</div>
<div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center">
<div className="relative size-20">
<svg className="size-full -rotate-90" viewBox="0 0 36 36">
<path className="text-[#222f49]" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="currentColor" strokeWidth="3"></path>
<path
className="text-primary transition-all duration-1000 ease-out"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeDasharray={`${Math.min(hipsterScore, 100)}, 100`}
strokeWidth="3"
></path>
</svg>
<div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-sm font-bold text-white">{hipsterScore.toFixed(0)}%</span>
<div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center group">
<div className="flex items-center gap-4">
<div className="text-center">
<Tooltip text="Concentration score (HHI). High means you focus on few artists, low means you spread your listening.">
<div className="text-xl font-bold text-white">{(1 - concentration).toFixed(2)}</div>
<div className="text-slate-500 text-[9px] uppercase tracking-tighter">Variety</div>
</Tooltip>
</div>
<div className="w-px h-8 bg-slate-700"></div>
<div className="text-center">
<Tooltip text={`Your peak listening time is around ${peakHour}.`}>
<div className="text-xl font-bold text-white">{peakHour}</div>
<div className="text-slate-500 text-[9px] uppercase tracking-tighter">Peak Time</div>
</Tooltip>
</div>
</div>
<div className="text-slate-400 text-[10px] uppercase tracking-wider mt-2">Hipster Score</div>
<div className="text-slate-500 text-[9px] mt-1">Obscurity: {obscurityRating.toFixed(0)}%</div>
</div>
</div>
</section>

View File

@@ -0,0 +1,25 @@
import React, { useState } from 'react';
const Tooltip = ({ text, children }) => {
const [isVisible, setIsVisible] = useState(false);
return (
<div
className="relative flex items-center group"
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{children}
{isVisible && (
<div className="absolute z-50 px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-100 -top-12 left-1/2 -translate-x-1/2 whitespace-nowrap dark:bg-gray-700">
{text}
<div className="absolute w-2 h-2 bg-gray-900 rotate-45 -bottom-1 left-1/2 -translate-x-1/2 dark:bg-gray-700"></div>
</div>
)}
</div>
);
};
export default Tooltip;