diff --git a/.env.example b/.env.example index 9dd12bb..916ff12 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= -REDIRECT_URI= +DOMAIN= GOOGLE_GENERATIVE_AI_API_KEY= REDIS_URL= \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fd07c02..abaa1ae 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,14 +4,12 @@ on: push: branches: - main - pull_request: - branches: - '**' env: PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} REGION: ${{ vars.GCP_REGION }} - SERVICE_NAME: liner-notes + SERVICE_NAME: ${{ github.ref == 'refs/heads/main' && 'liner-notes' || 'liner-notes-dev' }} jobs: deploy: @@ -33,7 +31,7 @@ jobs: run: | echo "SPOTIFY_CLIENT_ID=${{ vars.SPOTIFY_CLIENT_ID }}" >> .env echo "SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }}" >> .env - echo "REDIRECT_URI=${{ vars.REDIRECT_URI }}" >> .env + echo "DOMAIN=${{ github.ref == 'refs/heads/main' && vars.DOMAIN || vars.DEV_DOMAIN }}" >> .env echo "GOOGLE_GENERATIVE_AI_API_KEY=${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}" >> .env echo "REDIS_URL=${{ vars.REDIS_URL }}" >> .env echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env diff --git a/README.md b/README.md index d4f512a..f401845 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,15 @@ A powerful web application that enhances your music listening experience by prov ## 🚀 Features -- **Real-time Spotify Integration**: Connects to your Spotify account to monitor your "Now Playing" status. -- **AI-Powered Deep Research**: Uses Google Gemini and AI-integrated Google Search to analyze: +- **Real-time Spotify Integration**: Connects to the users Spotify account to analyse their library. +- **AI-Powered Deep Research**: Uses Google Gemini and AI-integrated Google Search to analyse: - **Album Context**: Critical reception, production style, and release history. - **Personnel & Places**: Details on producers, musicians, and studios. - **Artist Biography**: Focused on the artist's career during the specific era of the song. - **Cultural Context**: Explores the musical scene, genre movements, and cultural climate. - **Curated Recommendations**: Get actionable suggestions for related albums, tracks, books, films, and TV shows. -- **Track History**: Keeps a record of previously analyzed songs for easy reference. +- **Track History**: Keeps a record of previously analysed songs for easy reference. +- **Graph View**: Graph representation of realted tracks via intersection of tags. - **Real-time Updates**: Uses Server-Sent Events (SSE) to stream analysis results as they are generated. ## 🛠 Tech Stack @@ -58,7 +59,7 @@ Before you begin, ensure you have the following: ```env SPOTIFY_CLIENT_ID=your_spotify_client_id SPOTIFY_CLIENT_SECRET=your_spotify_client_secret - REDIRECT_URI=http://localhost:8080/callback + DOMAIN={ngrok https tunnel domain} GOOGLE_GENERATIVE_AI_API_KEY=your_google_ai_key REDIS_URL=redis://localhost:6379 JWT_SECRET=your_random_jwt_secret diff --git a/src/analysis.ts b/src/analysis.ts index 875c033..5027925 100644 --- a/src/analysis.ts +++ b/src/analysis.ts @@ -4,39 +4,54 @@ import { z } from 'zod'; import { AnalysisData, TrackDetails } from "./types"; import * as Store from "./store"; import * as Events from "./events"; +import { defaultAnalysis } from "./consts"; const MODEL_NAME = process.env.MODEL_NAME || "gemini-3-flash-preview"; -export async function generateContent(track: TrackDetails) { - console.log(`Starting research for ${track.name} - ${track.album} - ${track.artist}`) +const maxWord = 'Max 100 Words'; +const ANALYSIS_SHAPE = { + trackAnalysis: z.string().describe(`Analysis of the track's release, critical reception, and production style. ${maxWord}`), + albumAnalysis: z.string().describe(`Analysis of the album's release, critical reception, and production style. ${maxWord}`), + personnelAndPlaces: z.string().describe(`Details on the people (producers, musicians) and places (studios, cities) involved. ${maxWord}`), + artistBiography: z.string().describe(`Biography focusing on where the artist was in their career during this specific era. ${maxWord}`), + culturalContext: z.string().describe(`The musical scene, genre movements, or cultural climate of the time. ${maxWord}`), + recommendations: z.object({ + albums: z.array(z.object({ + name: z.string(), + creator: z.string(), + reasoning: z.string(), + })).describe("Distinct, actionable album recommendations, with reasoning."), + tracks: z.array(z.object({ + name: z.string(), + creator: z.string(), + reasoning: z.string(), + })).describe("Distinct, actionable track recommendations, with reasoning."), + books: z.array(z.object({ + name: z.string(), + creator: z.string(), + reasoning: z.string(), + })).describe("Distinct, actionable book recommendations, with reasoning."), + filmsAndTV: z.array(z.object({ + name: z.string(), + creator: z.string(), + reasoning: z.string(), + })).describe("Distinct, actionable film and TV recommendations, with reasoning."), + }), + tags: z.array(z.string()).describe("Tags describing the song, album, artist, and cultural context."), +}; +export async function runAnalysis(track: TrackDetails) { try { - const prompt = - "You are an expert musicologist and cultural historian. \n" + - `Analyze the song "${track.name}" by "${track.artist}" from the album "${track.album}" (${track.releaseDate || 'unknown date'}).\n` + - "Use the Google Search tool to ensure your factual details (dates, personnel, reception, recommendations, etc) are accurate. \n" + - "Fill out the schema fields concisely in full sentences. \n" + - "Focus on novel information which would be interesting to someone who already knows a lot about music. \n" + - "Ensure a none conversational style, the output will be shown directly to the user."; - - const { output } = await generateText({ - model: google(MODEL_NAME), - output: schema, - tools: { - google_search: google.tools.googleSearch({}), - }, - stopWhen: stepCountIs(3), - prompt, - }); - const analysis: AnalysisData = output; + console.info(`Starting research for ${track.name} - ${track.album} - ${track.artist}`) + const analysis: AnalysisData = await generateResearch(track); const updatedTrack: TrackDetails = { ...track, loading: false, analysis: { ...analysis } } - Store.updateCache(track.trackId, updatedTrack) + Store.updateTrack(track.trackId, updatedTrack) Events.analysis.emit(Events.analysisUpdate, updatedTrack); - console.log(`Finished research for ${track.name} - ${track.album} - ${track.artist}`) + console.info(`Finished research for ${track.name} - ${track.album} - ${track.artist}`) } catch (error) { console.error(`Analysis error for ${track.trackId}:`, error); const updatedTrack: TrackDetails = { @@ -44,55 +59,65 @@ export async function generateContent(track: TrackDetails) { loading: false, error: 'Error retrieving research' } - Store.updateCache(track.trackId, updatedTrack) + Store.updateTrack(track.trackId, updatedTrack) Events.analysis.emit(Events.analysisUpdate, updatedTrack); } } -const schema = Output.object({ - schema: z.object({ - trackAnalysis: z.string().describe("Analysis of the track's release, critical reception, and production style."), - albumAnalysis: z.string().describe("Analysis of the album's release, critical reception, and production style."), - personnelAndPlaces: z.string().describe("Details on the people (producers, musicians) and places (studios, cities) involved."), - artistBiography: z.string().describe("Biography focusing on where the artist was in their career during this specific era."), - culturalContext: z.string().describe("The musical scene, genre movements, or cultural climate of the time."), - recommendations: z.object({ - albums: z.array(z.object({ - name: z.string(), - creator: z.string(), - reasoning: z.string(), - })).describe("Distinct, actionable album recommendations, with reasoning."), - tracks: z.array(z.object({ - name: z.string(), - creator: z.string(), - reasoning: z.string(), - })).describe("Distinct, actionable track recommendations, with reasoning."), - books: z.array(z.object({ - name: z.string(), - creator: z.string(), - reasoning: z.string(), - })).describe("Distinct, actionable book recommendations, with reasoning."), - filmsAndTV: z.array(z.object({ - name: z.string(), - creator: z.string(), - reasoning: z.string(), - })).describe("Distinct, actionable film and TV recommendations, with reasoning."), - }), - tags: z.array(z.string()).describe("Tags describing the song, album, artist, and cultural context."), - // relatedArtists: z.array(z.object({ - // name: z.string(), - // artist: z.string(), - // reasoning: z.string(), - // })).describe("Distinct, actionable related artists recommendations, with reasoning."), - // relatedTracks: z.array(z.object({ - // name: z.string(), - // artist: z.string(), - // reasoning: z.string(), - // })).describe("Distinct, actionable related tracks recommendations, with reasoning."), - // relatedAlbums: z.array(z.object({ - // name: z.string(), - // artist: z.string(), - // reasoning: z.string(), - // })).describe("Distinct, actionable related albums recommendations, with reasoning."), - }) -}) \ No newline at end of file +// this function will generate text for fields that are not filled +const generateResearch = async (track: TrackDetails) => { + const analysis: AnalysisData = track.analysis || {}; + const prompt = + "You are an expert musicologist and cultural historian. \n" + + `Analyze the song "${track.name}" by "${track.artist}" from the album "${track.album}" (${track.releaseDate || 'unknown date'}).\n` + + "Use the Google Search tool to ensure your factual details (dates, personnel, reception, recommendations, etc) are accurate. \n" + + "Fill out the schema fields concisely in full sentences. \n" + + "Focus on novel information which would be interesting to someone who already knows a lot about music. \n" + + "Ensure a none conversational style, the output will be shown directly to the user."; + + // conditionally create the schema for the model to fill in. + const keysToFill = Object + .keys(ANALYSIS_SHAPE) + .filter((key) => !valueIsFilled(analysis?.[key as keyof AnalysisData])); + + if (keysToFill.length === 0) { + return {}; + } + + const partialSchema = Output.object({ + schema: z.object( + Object.fromEntries( + keysToFill.map((key) => [key, ANALYSIS_SHAPE[key as keyof typeof ANALYSIS_SHAPE]]) + ) + ) + }); + + const { output } = await generateText({ + model: google(MODEL_NAME), + output: partialSchema, + tools: { + google_search: google.tools.googleSearch({}), + }, + stopWhen: stepCountIs(3), + prompt, + }); + return { ...analysis, ...output }; +} + +export const trackAnalysisFilled = (track: TrackDetails) => { + return Object + .entries(defaultAnalysis) + .every(([key]) => valueIsFilled(track.analysis?.[key as keyof typeof defaultAnalysis])); +}; + +export const valueIsFilled = (value: any) => { + if (Array.isArray(value)) { + return value.length > 0; + } + + if (typeof value === 'object' && value !== null) { + return true; + } + + return value !== undefined && value !== null && value !== ''; +}; diff --git a/src/consts.ts b/src/consts.ts index 35a7534..be8a6e6 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -1,10 +1,22 @@ import path from "node:path"; +import { AnalysisData } from "./types"; export const PORT = process.env.PORT ?? 8080; export const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID as string; export const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET as string; -export const REDIRECT_URI = process.env.REDIRECT_URI as string; +export const DOMAIN = process.env.DOMAIN as string; +export const REDIRECT_URI = `https://${DOMAIN}/callback`; export const JWT_SECRET = process.env.JWT_SECRET as string; export const UI_PATH = path.join(process.cwd(), 'dist', 'ui'); -export const cookieName = 'auth_token' \ No newline at end of file +export const cookieName = 'auth_token' + +export const defaultAnalysis: AnalysisData = { + trackAnalysis: undefined, + albumAnalysis: undefined, + personnelAndPlaces: undefined, + artistBiography: undefined, + culturalContext: undefined, + recommendations: undefined, + tags: [], +} diff --git a/src/server.ts b/src/server.ts index ccf39ca..6dfaa1e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,14 +2,14 @@ import 'dotenv/config'; import express, { Request, Response } from 'express'; import axios from 'axios'; -import { SessionData, SpotifyTokenResponse, TrackDetails } from './types'; +import { SpotifyTokenResponse, TrackDetails } from './types'; import * as Store from './store'; import * as Spotify from './spotify'; import * as Analysis from './analysis'; import * as Events from "./events"; import redisClient, { connectRedis } from './redis'; import jwt from 'jsonwebtoken'; -import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, cookieName, JWT_SECRET, PORT, REDIRECT_URI, UI_PATH } from './consts'; +import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, cookieName, JWT_SECRET, PORT, REDIRECT_URI, UI_PATH, defaultAnalysis } from './consts'; import cookieParser from 'cookie-parser'; import { AuthenticatedRequest, authenticateJWT } from './middleware'; @@ -102,20 +102,11 @@ app.get('/api/now-playing', authenticateJWT, async (req: AuthenticatedRequest, r return res.status(401).json({ error: 'Not logged in' }); } try { - const track = await Spotify.getCurrentlyPlaying(user)  + const track = await Spotify.getCurrentlyPlaying(user) if (!track) return res.status(200).json(null) - const trackDetails: TrackDetails = { - ...Spotify.convertSpotifyTrackToTrackDetails(track), - loading: true, - } - await Store.pushHistory(user.spotifyId, trackDetails.trackId); - let cached = await Store.getCacheTrack(trackDetails.trackId); - if (cached) { - res.status(200).json(cached); - return; + let trackDetails: TrackDetails = { + ...Spotify.convertSpotifyTrackToTrackDetails(track) } - await Store.updateCache(trackDetails.trackId, trackDetails); - Analysis.generateContent(trackDetails); res.status(200).json(trackDetails); } catch (error: any) { console.error('Error during now playing', error); @@ -141,29 +132,30 @@ app.get('/api/recently-played', authenticateJWT, async (req: AuthenticatedReques } }) -app.get('/api/analyse', authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { +app.post('/api/analyse', authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { try { const user = req.user; if (!user) return res.status(401).json({ error: 'Not logged in' }); const trackId = req.query.trackId as string; - let track = await Store.getCacheTrack(trackId); + let track = await Store.getTrack(trackId); if (!track) { const spotifyTrack = await Spotify.getTrack(trackId, user); track = Spotify.convertSpotifyTrackToTrackDetails(spotifyTrack); await Store.pushHistory(user.spotifyId, track.trackId); } - if (track.analysis || track.loading) { + if (Analysis.trackAnalysisFilled(track)) { res.status(200).json({ message: 'Analysis already completed' }); return; } const updatedTrack = { ...track, loading: true, - error: undefined + error: undefined, + analysis: { ...defaultAnalysis, ...track.analysis }, }; - await Store.updateCache(track.trackId, updatedTrack); + await Store.updateTrack(track.trackId, updatedTrack); Events.analysis.emit(Events.analysisUpdate, updatedTrack); - Analysis.generateContent(updatedTrack); + Analysis.runAnalysis(updatedTrack); res.status(200).json({ message: 'Analysis triggered' }); } catch (error: any) { console.error('Error during analyse', error?.message); @@ -183,9 +175,14 @@ app.get('/api/events', authenticateJWT, (req: AuthenticatedRequest, res) => { app.get('/api/related-tracks', authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { try { const user = req.user; + const limit = req.query.limit as string ?? 5; if (!user) return res.status(401).json({ error: 'Not logged in' }); const trackId = req.query.trackId as string; - const relatedTracks = await Store.getRelatedTracks(trackId, user.spotifyId); + const relatedTracks = await Store.getRelatedTracks( + trackId, + user.spotifyId, + parseInt(limit) + ); res.json(relatedTracks); } catch (error: any) { console.error('Error during related tracks', error?.message); diff --git a/src/spotify.ts b/src/spotify.ts index 33c717b..37c1092 100644 --- a/src/spotify.ts +++ b/src/spotify.ts @@ -1,6 +1,6 @@ import axios from "axios"; import { SpotifyNowPlayingResponse, SpotifyTrack, TrackDetails, SessionData, SpotifyTokenResponse, SpotifyUser } from "./types"; -import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET } from "./consts"; +import { defaultAnalysis, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET } from "./consts"; export const getRefreshedAccessToken = async (refreshToken: string): Promise => { const params = new URLSearchParams({ @@ -87,6 +87,7 @@ export const convertSpotifyTrackToTrackDetails = (track: SpotifyTrack): TrackDet image: track.album.images[0].url, releaseDate: track.album.release_date, loading: false, + analysis: defaultAnalysis, }; } diff --git a/src/store.ts b/src/store.ts index 982ff47..5fd5ee0 100644 --- a/src/store.ts +++ b/src/store.ts @@ -6,12 +6,14 @@ const HISTORY_KEY_PREFIX = "history:"; const getUserHistoryKey = (userId: string) => `${HISTORY_KEY_PREFIX}${userId}`; const getTrackKey = (trackId: string) => `${TRACK_KEY_PREFIX}${trackId}`; +// -export const updateCache = async (trackId: string, trackDetails: TrackDetails) => { +export const updateTrack = async (trackId: string, trackDetails: TrackDetails) => { + // handle the different fields await redisClient.set(getTrackKey(trackId), JSON.stringify(trackDetails)); }; -export const getCacheTrack = async (trackId: string): Promise => { +export const getTrack = async (trackId: string): Promise => { const data = await redisClient.get(getTrackKey(trackId)); return data ? JSON.parse(data) : null; }; @@ -47,7 +49,7 @@ export const getTrackHistory = async (userId: string, count = 50): Promise { - const track = await getCacheTrack(trackId); + const track = await getTrack(trackId); if (!track || !track.analysis) return []; const history = await getTrackHistory(userId); const topTracks = findTracksWithCommonTags(track, history).slice(0, count); @@ -58,6 +60,7 @@ const findTracksWithCommonTags = (track: TrackDetails, tracks: TrackDetails[]) = const targetTags = track?.analysis?.tags ?? []; const commonTracks = tracks.reduce((prev, curr) => { if (curr.trackId === track.trackId) return prev; + if (prev.some(t => t.trackId === curr.trackId)) return prev; if (curr.analysis?.tags?.length === 0) return prev; if (curr.analysis?.tags?.some(tag => targetTags.includes(tag))) { return [...prev, curr]; diff --git a/src/types.ts b/src/types.ts index 3707923..a92e91c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,7 +40,7 @@ export interface TrackDetails { album: string; releaseDate: string; image: string; - analysis?: AnalysisData; + analysis: AnalysisData; error?: string; loading: boolean; } @@ -52,18 +52,18 @@ export interface Recommendation { } export interface AnalysisData { - albumAnalysis: string; - trackAnalysis: string; - personnelAndPlaces: string; - artistBiography: string; - culturalContext: string; - recommendations: { + albumAnalysis?: string; + trackAnalysis?: string; + personnelAndPlaces?: string; + artistBiography?: string; + culturalContext?: string; + recommendations?: { albums: Recommendation[]; tracks: Recommendation[]; books: Recommendation[]; filmsAndTV: Recommendation[]; }; - tags: string[]; + tags?: string[]; } export interface SessionData { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index cbbb05a..d7506cd 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } from 'react'; import axios from 'axios'; import type { TrackDetails } from '../../src/types'; import Home from './components/home'; @@ -8,32 +8,6 @@ import { ThemeProvider } from 'next-themes'; function App() { const [history, setHistory] = useState([]); const [isLoggedIn, setIsLoggedIn] = useState(false); - const [isAnalyzing, setIsAnalyzing] = useState(false); - - const pollInterval = useRef | null>(null); - - const pollCurrentlyPlaying = async () => { - try { - const track = await Api.getCurrentTrack() - if (track === null) return - setHistory((prev) => { - if (prev.length === 0) { - return [track]; - } - const firstTrack = prev[0]; - if (track.trackId !== firstTrack.trackId) { - return [track, ...prev]; - } - return prev; - }); - } catch (error) { - console.error("Polling error", error); - // If we get a 401/404, we're not logged in - if (axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 404)) { - setIsLoggedIn(false); - } - } - }; const fetchHistory = async () => { try { @@ -51,18 +25,6 @@ function App() { } useEffect(() => { fetchHistory() }, []); - useEffect(() => { - const runAnalysis = async () => { - if (!isAnalyzing || !isLoggedIn) return - pollCurrentlyPlaying() - pollInterval.current = setInterval(pollCurrentlyPlaying, 5000); - } - runAnalysis(); - return () => { - if (pollInterval.current) clearInterval(pollInterval.current); - }; - }, [isAnalyzing, isLoggedIn]); - useEffect(() => { if (!isLoggedIn) return const eventSource = new EventSource(Api.eventsURL); @@ -92,8 +54,6 @@ function App() { > diff --git a/ui/src/components/analyse-recent.tsx b/ui/src/components/analyse-recent.tsx index 8538162..ba33b81 100644 --- a/ui/src/components/analyse-recent.tsx +++ b/ui/src/components/analyse-recent.tsx @@ -7,10 +7,10 @@ import * as Api from "../lib/api"; import { useState } from "react"; export default function AnalyseRecent({ - setHistory, + handleAnalyseTrack, history, }: { - setHistory: React.Dispatch>> + handleAnalyseTrack: (track: TrackDetails) => void history: Array }) { const [loading, setLoading] = useState(false); @@ -29,13 +29,6 @@ export default function AnalyseRecent({ } } - const handleAnalyseTrack = (track: TrackDetails) => { - setHistory((prev) => [ - { ...track, loading: true }, - ...prev - ]); - Api.analyseTrack(track.trackId) - } return ( { diff --git a/ui/src/components/analysis.tsx b/ui/src/components/analysis.tsx index 4ddbb73..6098c97 100644 --- a/ui/src/components/analysis.tsx +++ b/ui/src/components/analysis.tsx @@ -1,49 +1,87 @@ import type { AnalysisData } from '../../../src/types'; import { cn } from '../lib/utils'; import { Disc3, Music, BookOpen, Film } from 'lucide-react' -import { Button } from './ui/button'; -import * as Api from '../lib/api'; -export default function Insights({ +export function AnalysisCard({ analysis, - loading, - error, - trackId }: { - analysis?: AnalysisData, - loading?: boolean, - error?: string, - trackId?: string + analysis: AnalysisData, }) { + const recommendations: RecommendationWithType[] = [ + analysis?.recommendations?.albums?.map(i => ({ ...i, type: 'album' as MediaType })) || [], + analysis?.recommendations?.tracks?.map(i => ({ ...i, type: 'track' as MediaType })) || [], + analysis?.recommendations?.books?.map(i => ({ ...i, type: 'book' as MediaType })) || [], + analysis?.recommendations?.filmsAndTV?.map(i => ({ ...i, type: 'filmAndTV' as MediaType })) || [], + ].flat().filter((item) => !!item); + return (
- {analysis && } - {loading && ( -
- <>Researching Track... -
- )} - {!analysis && !loading && error && ( -
-
-

{error}

- {trackId && ( - - )} -
+
+
+
+ {analysis?.trackAnalysis + ? analysis.trackAnalysis + : + } +
+
+ {analysis?.albumAnalysis + ? analysis.albumAnalysis + : + } +
+
+ {analysis?.personnelAndPlaces + ? analysis.personnelAndPlaces + : + } +
+
+ {analysis?.artistBiography + ? analysis.artistBiography + : + } +
+
+ {analysis?.culturalContext + ? analysis.culturalContext + : + } +
+
+
+ {recommendations && recommendations.map((item, index) => ( +
+
+ {getMediaIcon(item.type)} +
+

{item.name}

+

{item.creator}

+
+
+

{item.reasoning}

+
+ ))} + {!recommendations.length && ( + Array.from({ length: 3 }, (_, index) => ( +
+ )) + )} +
+
- )} +
) } -const Section = ({ title, children }: { title: string; children: React.ReactNode }) => ( -
+const Section = ({ title, children }: { title: string; children: React.ReactNode; }) => ( +

{title}

@@ -53,6 +91,10 @@ const Section = ({ title, children }: { title: string; children: React.ReactNode
); +const LoadingSection = () => ( +
+); + type MediaType = 'album' | 'track' | 'book' | 'filmAndTV'; interface RecommendationWithType { @@ -66,51 +108,12 @@ const getMediaIcon = (type: MediaType) => { const iconSize = 28; switch (type) { case 'album': - return ; + return ; case 'track': - return ; + return ; case 'book': - return ; + return ; case 'filmAndTV': - return ; + return ; } }; - -export const AnalysisView = ({ data }: { data: AnalysisData; }) => { - if (!data) return null; - - const recommendations: RecommendationWithType[] = [ - data?.recommendations?.albums?.map(i => ({ ...i, type: 'album' as MediaType })) || [], - data?.recommendations?.tracks?.map(i => ({ ...i, type: 'track' as MediaType })) || [], - data?.recommendations?.books?.map(i => ({ ...i, type: 'book' as MediaType })) || [], - data?.recommendations?.filmsAndTV?.map(i => ({ ...i, type: 'filmAndTV' as MediaType })) || [], - ].flat().filter((item) => !!item); - - return ( -
-
-
{data.trackAnalysis}
-
{data.albumAnalysis}
-
{data.personnelAndPlaces}
-
{data.artistBiography}
-
{data.culturalContext}
-
-
- {recommendations && recommendations.map((item, index) => ( -
-
- {getMediaIcon(item.type)} -
-

{item.name}

-

{item.creator}

-
-
-

{item.reasoning}

-
- ))} -
-
-
-
- ); -}; \ No newline at end of file diff --git a/ui/src/components/eq-visualizer.tsx b/ui/src/components/eq-visualizer.tsx deleted file mode 100644 index 3292039..0000000 --- a/ui/src/components/eq-visualizer.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { cn } from "../lib/utils"; - -interface EqVisualizerProps { - className?: string; - barCount?: number; - isPlaying?: boolean; -} - -export function EqVisualizer({ - className, - barCount = 4, - isPlaying = true -}: EqVisualizerProps) { - return ( -