From d74cb9404ccb9338d458c0e40dd119aabbc441a5 Mon Sep 17 00:00:00 2001 From: ruarim Date: Mon, 26 Jan 2026 16:46:55 +0000 Subject: [PATCH 01/10] v1 --- src/analysis.ts | 144 ++++++++++++++++++--------------- src/consts.ts | 18 ++++- src/server.ts | 33 +++++--- src/spotify.ts | 3 +- src/store.ts | 8 +- src/types.ts | 16 ++-- src/utils.ts | 12 +++ ui/src/components/analysis.tsx | 64 ++++++++++----- ui/src/components/home.tsx | 12 +-- 9 files changed, 195 insertions(+), 115 deletions(-) create mode 100644 src/utils.ts diff --git a/src/analysis.ts b/src/analysis.ts index 875c033..87406ea 100644 --- a/src/analysis.ts +++ b/src/analysis.ts @@ -7,34 +7,51 @@ import * as Events from "./events"; const MODEL_NAME = process.env.MODEL_NAME || "gemini-3-flash-preview"; +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."), +}; + +// add a failed to each field so we dont get stuck in a loop? + export async function generateContent(track: TrackDetails) { console.log(`Starting research for ${track.name} - ${track.album} - ${track.artist}`) 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; + const analysis: AnalysisData = await handleGenerateResearch(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}`) } catch (error) { @@ -44,55 +61,48 @@ 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 handleGenerateResearch = 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) => !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 }; +} diff --git a/src/consts.ts b/src/consts.ts index 35a7534..f2349a2 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -1,4 +1,5 @@ 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; @@ -7,4 +8,19 @@ export const REDIRECT_URI = process.env.REDIRECT_URI as string; 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: { + albums: [], + tracks: [], + books: [], + filmsAndTV: [], + }, + tags: [], +} diff --git a/src/server.ts b/src/server.ts index ccf39ca..e2278e5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,16 +2,17 @@ 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'; +import { trackAnalysisFilled } from './utils'; const app = express(); @@ -102,19 +103,26 @@ 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 = { + let trackDetails: TrackDetails = { ...Spotify.convertSpotifyTrackToTrackDetails(track), loading: true, } await Store.pushHistory(user.spotifyId, trackDetails.trackId); - let cached = await Store.getCacheTrack(trackDetails.trackId); - if (cached) { + let cached = await Store.getTrack(trackDetails.trackId); + + // Either have filled all the filed or are still waiting for the analysis to finish + if (cached && (trackAnalysisFilled(cached) || cached.loading)) { res.status(200).json(cached); return; } - await Store.updateCache(trackDetails.trackId, trackDetails); + // Still have some fields to fill and we are not waiting for the analysis to finish + if (cached && !(trackAnalysisFilled(cached) && !cached.loading)) { + trackDetails = { ...cached, loading: true}; + } + + await Store.updateTrack(trackDetails.trackId, trackDetails); Analysis.generateContent(trackDetails); res.status(200).json(trackDetails); } catch (error: any) { @@ -146,22 +154,25 @@ app.get('/api/analyse', authenticateJWT, async (req: AuthenticatedRequest, res: 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 (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); res.status(200).json({ message: 'Analysis triggered' }); 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..b46e027 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); 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/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..329eca2 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,12 @@ +import { defaultAnalysis } from "./consts"; +import { TrackDetails } from "./types"; + +export const trackAnalysisFilled = (track: TrackDetails) => { + return Object + .entries(defaultAnalysis) + .every(([key, _]) => + track.analysis?.[key as keyof typeof defaultAnalysis] !== undefined && + track.analysis?.[key as keyof typeof defaultAnalysis] !== null && + track.analysis?.[key as keyof typeof defaultAnalysis] !== '' + ); +}; \ No newline at end of file diff --git a/ui/src/components/analysis.tsx b/ui/src/components/analysis.tsx index 4ddbb73..62ef4f4 100644 --- a/ui/src/components/analysis.tsx +++ b/ui/src/components/analysis.tsx @@ -10,7 +10,7 @@ export default function Insights({ error, trackId }: { - analysis?: AnalysisData, + analysis: AnalysisData, loading?: boolean, error?: string, trackId?: string @@ -18,14 +18,8 @@ export default function Insights({ return (
- {analysis && } - {loading && ( -
- <>Researching Track... -
- )} + {!analysis && !loading && error && (
@@ -42,8 +36,8 @@ export default function Insights({ ) } -const Section = ({ title, children }: { title: string; children: React.ReactNode }) => ( -
+const Section = ({ title, children }: { title: string; children: React.ReactNode; }) => ( +

{title}

@@ -76,9 +70,7 @@ const getMediaIcon = (type: MediaType) => { } }; -export const AnalysisView = ({ data }: { data: AnalysisData; }) => { - if (!data) return null; - +export const AnalysisView = ({ data }: { data: AnalysisData }) => { const recommendations: RecommendationWithType[] = [ data?.recommendations?.albums?.map(i => ({ ...i, type: 'album' as MediaType })) || [], data?.recommendations?.tracks?.map(i => ({ ...i, type: 'track' as MediaType })) || [], @@ -86,14 +78,42 @@ export const AnalysisView = ({ data }: { data: AnalysisData; }) => { data?.recommendations?.filmsAndTV?.map(i => ({ ...i, type: 'filmAndTV' as MediaType })) || [], ].flat().filter((item) => !!item); + const LoadingSection = () => ( +
+ ); return (
-
{data.trackAnalysis}
-
{data.albumAnalysis}
-
{data.personnelAndPlaces}
-
{data.artistBiography}
-
{data.culturalContext}
+
+ {data?.trackAnalysis + ? data.trackAnalysis + : + } +
+
+ {data?.albumAnalysis + ? data.albumAnalysis + : + } +
+
+ {data?.personnelAndPlaces + ? data.personnelAndPlaces + : + } +
+
+ {data?.artistBiography + ? data.artistBiography + : + } +
+
+ {data?.culturalContext + ? data.culturalContext + : + } +
{recommendations && recommendations.map((item, index) => ( @@ -108,6 +128,14 @@ export const AnalysisView = ({ data }: { data: AnalysisData; }) => {

{item.reasoning}

))} + {!recommendations.length && ( + Array.from({ length: 3 }, (_, index) => ( +
+ )) + )}
diff --git a/ui/src/components/home.tsx b/ui/src/components/home.tsx index f9da406..1438bfa 100644 --- a/ui/src/components/home.tsx +++ b/ui/src/components/home.tsx @@ -109,12 +109,12 @@ export default function Home({
- + {selectedTrack && }
From 6992d5e3a78bba03b336caeaf41decff841b4c50 Mon Sep 17 00:00:00 2001 From: ruarim Date: Mon, 26 Jan 2026 16:51:32 +0000 Subject: [PATCH 02/10] deploy dev env --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fd07c02..066dcb3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,7 +11,7 @@ on: 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: From 2bc1c08412eed681c5b9a96ea9512040a410932b Mon Sep 17 00:00:00 2001 From: ruarim Date: Mon, 26 Jan 2026 19:48:57 +0000 Subject: [PATCH 03/10] refactor --- src/analysis.ts | 23 ++++++++++++++++++++--- src/server.ts | 7 +++---- src/utils.ts | 12 ------------ 3 files changed, 23 insertions(+), 19 deletions(-) delete mode 100644 src/utils.ts diff --git a/src/analysis.ts b/src/analysis.ts index 87406ea..f4431c6 100644 --- a/src/analysis.ts +++ b/src/analysis.ts @@ -4,6 +4,7 @@ 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"; @@ -39,8 +40,6 @@ const ANALYSIS_SHAPE = { tags: z.array(z.string()).describe("Tags describing the song, album, artist, and cultural context."), }; -// add a failed to each field so we dont get stuck in a loop? - export async function generateContent(track: TrackDetails) { console.log(`Starting research for ${track.name} - ${track.album} - ${track.artist}`) @@ -80,7 +79,7 @@ const handleGenerateResearch = async (track: TrackDetails) => { // conditionally create the schema for the model to fill in. const keysToFill = Object .keys(ANALYSIS_SHAPE) - .filter((key) => !analysis[key as keyof AnalysisData]); + .filter((key) => !valueIsFilled(analysis?.[key as keyof AnalysisData])); if (keysToFill.length === 0) { return {}; @@ -106,3 +105,21 @@ const handleGenerateResearch = async (track: TrackDetails) => { 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/server.ts b/src/server.ts index e2278e5..1f74834 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,7 +12,6 @@ import jwt from 'jsonwebtoken'; 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'; -import { trackAnalysisFilled } from './utils'; const app = express(); @@ -113,12 +112,12 @@ app.get('/api/now-playing', authenticateJWT, async (req: AuthenticatedRequest, r let cached = await Store.getTrack(trackDetails.trackId); // Either have filled all the filed or are still waiting for the analysis to finish - if (cached && (trackAnalysisFilled(cached) || cached.loading)) { + if (cached && (Analysis.trackAnalysisFilled(cached) || cached.loading)) { res.status(200).json(cached); return; } // Still have some fields to fill and we are not waiting for the analysis to finish - if (cached && !(trackAnalysisFilled(cached) && !cached.loading)) { + if (cached && !(Analysis.trackAnalysisFilled(cached) && !cached.loading)) { trackDetails = { ...cached, loading: true}; } @@ -161,7 +160,7 @@ app.get('/api/analyse', authenticateJWT, async (req: AuthenticatedRequest, res: await Store.pushHistory(user.spotifyId, track.trackId); } - if (trackAnalysisFilled(track)) { + if (Analysis.trackAnalysisFilled(track)) { res.status(200).json({ message: 'Analysis already completed' }); return; } diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 329eca2..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defaultAnalysis } from "./consts"; -import { TrackDetails } from "./types"; - -export const trackAnalysisFilled = (track: TrackDetails) => { - return Object - .entries(defaultAnalysis) - .every(([key, _]) => - track.analysis?.[key as keyof typeof defaultAnalysis] !== undefined && - track.analysis?.[key as keyof typeof defaultAnalysis] !== null && - track.analysis?.[key as keyof typeof defaultAnalysis] !== '' - ); -}; \ No newline at end of file From b78ec5173bf3e0d938cfd05c4eca324181130823 Mon Sep 17 00:00:00 2001 From: ruarim Date: Tue, 27 Jan 2026 17:51:15 +0000 Subject: [PATCH 04/10] add retry button --- src/consts.ts | 7 +- ui/src/components/analysis.tsx | 163 ++++++++++++++------------------- ui/src/components/home.tsx | 16 ++-- ui/src/components/track.tsx | 15 +++ 4 files changed, 92 insertions(+), 109 deletions(-) diff --git a/src/consts.ts b/src/consts.ts index f2349a2..e3f7579 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -16,11 +16,6 @@ export const defaultAnalysis: AnalysisData = { personnelAndPlaces: undefined, artistBiography: undefined, culturalContext: undefined, - recommendations: { - albums: [], - tracks: [], - books: [], - filmsAndTV: [], - }, + recommendations: undefined, tags: [], } diff --git a/ui/src/components/analysis.tsx b/ui/src/components/analysis.tsx index 62ef4f4..6f2295c 100644 --- a/ui/src/components/analysis.tsx +++ b/ui/src/components/analysis.tsx @@ -1,37 +1,81 @@ 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 }) { + 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 && 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) => ( +
+ )) + )} +
+
- )} +
) } @@ -47,6 +91,10 @@ const Section = ({ title, children }: { title: string; children: React.ReactNode
); +const LoadingSection = () => ( +
+); + type MediaType = 'album' | 'track' | 'book' | 'filmAndTV'; interface RecommendationWithType { @@ -69,76 +117,3 @@ const getMediaIcon = (type: MediaType) => { return ; } }; - -export const AnalysisView = ({ data }: { data: AnalysisData }) => { - 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); - - const LoadingSection = () => ( -
- ); - return ( -
-
-
- {data?.trackAnalysis - ? data.trackAnalysis - : - } -
-
- {data?.albumAnalysis - ? data.albumAnalysis - : - } -
-
- {data?.personnelAndPlaces - ? data.personnelAndPlaces - : - } -
-
- {data?.artistBiography - ? data.artistBiography - : - } -
-
- {data?.culturalContext - ? data.culturalContext - : - } -
-
-
- {recommendations && recommendations.map((item, index) => ( -
-
- {getMediaIcon(item.type)} -
-

{item.name}

-

{item.creator}

-
-
-

{item.reasoning}

-
- ))} - {!recommendations.length && ( - Array.from({ length: 3 }, (_, index) => ( -
- )) - )} -
-
-
-
- ); -}; \ No newline at end of file diff --git a/ui/src/components/home.tsx b/ui/src/components/home.tsx index 1438bfa..024b50e 100644 --- a/ui/src/components/home.tsx +++ b/ui/src/components/home.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import type { TrackDetails } from "../../../src/types"; -import Insights from "./analysis"; +import { AnalysisCard } from "./analysis"; import { SidebarProvider, Sidebar, @@ -19,7 +19,7 @@ import PlatformLogin from "./platform-connect"; import { NavUser } from "./nav-user"; import { Button } from "./ui/button"; import AnalyseRecent from "./analyse-recent"; -import { TrackItem, TrackCard } from "./track"; +import { TrackItem, TrackCard, TrackError } from "./track"; import { ResizableOverlay } from "./resizable-overlay"; import { RelatedGraph } from "./related-graph"; import { useWindowSize } from "../hooks/use-window-size"; @@ -105,16 +105,14 @@ export default function Home({
-
+
+
- {selectedTrack && } + {selectedTrack && ( + + )}
diff --git a/ui/src/components/track.tsx b/ui/src/components/track.tsx index 1f3329c..e129b4f 100644 --- a/ui/src/components/track.tsx +++ b/ui/src/components/track.tsx @@ -1,6 +1,8 @@ import type { TrackDetails } from "../../../src/types" import { cn } from "../lib/utils" import { Loader2 } from "lucide-react" +import { Button } from "./ui/button" +import * as Api from "../lib/api" export function TrackItem({ track, selected }: { track: TrackDetails, selected: boolean }) { return <> @@ -112,6 +114,19 @@ export function TrackCard({ track }: { track?: TrackDetails }) { ) } +export function TrackError({ track }: { track?: TrackDetails }) { + if (!track || !track.error) return null; + return ( +
+
+

{track.error}

+ +
+
+ ) +} export function OverflowTags({ tags, From 95db37f88ad7a6050aaea0d8b6b6954977573355 Mon Sep 17 00:00:00 2001 From: ruarim Date: Tue, 27 Jan 2026 17:56:54 +0000 Subject: [PATCH 05/10] update workflow --- .github/workflows/deploy.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 066dcb3..68c9a88 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,8 +4,10 @@ on: push: branches: - main + - '**' pull_request: branches: + - main - '**' env: From 30f5fc61909ad2392e9fac5634033725bd29d743 Mon Sep 17 00:00:00 2001 From: ruarim Date: Tue, 27 Jan 2026 18:06:42 +0000 Subject: [PATCH 06/10] fix the deploy workflow --- .github/workflows/deploy.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 68c9a88..a0a2f9e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,10 +5,6 @@ on: branches: - main - '**' - pull_request: - branches: - - main - - '**' env: PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} From e449ebe566340209806dcb844972f42b4035c1c1 Mon Sep 17 00:00:00 2001 From: ruarim Date: Tue, 27 Jan 2026 18:22:22 +0000 Subject: [PATCH 07/10] swap to DOMAIN and set correct url on dev branch --- .env.example | 2 +- .github/workflows/deploy.yml | 2 +- README.md | 2 +- src/consts.ts | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) 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 a0a2f9e..abaa1ae 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,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..0d2f091 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,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/consts.ts b/src/consts.ts index e3f7579..be8a6e6 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -4,7 +4,8 @@ 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'); From 8408c1832368f45949c1804fe01743587e43c890 Mon Sep 17 00:00:00 2001 From: ruarim Date: Mon, 16 Feb 2026 22:25:53 +0000 Subject: [PATCH 08/10] ensure no duplicate related tracks --- src/server.ts | 7 ++++++- src/store.ts | 1 + ui/src/components/analysis.tsx | 8 ++++---- ui/src/components/related-graph.tsx | 6 +++--- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/server.ts b/src/server.ts index 1f74834..ca5daa3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -193,9 +193,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/store.ts b/src/store.ts index b46e027..5fd5ee0 100644 --- a/src/store.ts +++ b/src/store.ts @@ -60,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/ui/src/components/analysis.tsx b/ui/src/components/analysis.tsx index 6f2295c..6098c97 100644 --- a/ui/src/components/analysis.tsx +++ b/ui/src/components/analysis.tsx @@ -108,12 +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 ; } }; diff --git a/ui/src/components/related-graph.tsx b/ui/src/components/related-graph.tsx index b315060..d71e247 100644 --- a/ui/src/components/related-graph.tsx +++ b/ui/src/components/related-graph.tsx @@ -24,7 +24,7 @@ const TrackNode = ({ data }: { data: TrackNodeData }) => { return (
{
@@ -57,7 +57,7 @@ const TrackNode = ({ data }: { data: TrackNodeData }) => {
); From 5b6edcbe102185e5e4236858975ca5a3f57b6cfa Mon Sep 17 00:00:00 2001 From: ruarim Date: Mon, 16 Feb 2026 22:38:25 +0000 Subject: [PATCH 09/10] better naming --- src/analysis.ts | 14 ++++++-------- src/server.ts | 6 +++--- ui/src/components/track.tsx | 4 ++-- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/analysis.ts b/src/analysis.ts index f4431c6..5027925 100644 --- a/src/analysis.ts +++ b/src/analysis.ts @@ -40,11 +40,10 @@ const ANALYSIS_SHAPE = { tags: z.array(z.string()).describe("Tags describing the song, album, artist, and cultural context."), }; -export async function generateContent(track: TrackDetails) { - console.log(`Starting research for ${track.name} - ${track.album} - ${track.artist}`) - +export async function runAnalysis(track: TrackDetails) { try { - const analysis: AnalysisData = await handleGenerateResearch(track); + console.info(`Starting research for ${track.name} - ${track.album} - ${track.artist}`) + const analysis: AnalysisData = await generateResearch(track); const updatedTrack: TrackDetails = { ...track, loading: false, @@ -52,7 +51,7 @@ export async function generateContent(track: TrackDetails) { } 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 = { @@ -66,7 +65,7 @@ export async function generateContent(track: TrackDetails) { } // this function will generate text for fields that are not filled -const handleGenerateResearch = async (track: TrackDetails) => { +const generateResearch = async (track: TrackDetails) => { const analysis: AnalysisData = track.analysis || {}; const prompt = "You are an expert musicologist and cultural historian. \n" + @@ -102,9 +101,8 @@ const handleGenerateResearch = async (track: TrackDetails) => { stopWhen: stepCountIs(3), prompt, }); - return { ...analysis, ...output }; -} +} export const trackAnalysisFilled = (track: TrackDetails) => { return Object diff --git a/src/server.ts b/src/server.ts index ca5daa3..ad02ce1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -118,11 +118,11 @@ app.get('/api/now-playing', authenticateJWT, async (req: AuthenticatedRequest, r } // Still have some fields to fill and we are not waiting for the analysis to finish if (cached && !(Analysis.trackAnalysisFilled(cached) && !cached.loading)) { - trackDetails = { ...cached, loading: true}; + trackDetails = { ...cached, loading: true }; } await Store.updateTrack(trackDetails.trackId, trackDetails); - Analysis.generateContent(trackDetails); + Analysis.runAnalysis(trackDetails); res.status(200).json(trackDetails); } catch (error: any) { console.error('Error during now playing', error); @@ -173,7 +173,7 @@ app.get('/api/analyse', authenticateJWT, async (req: AuthenticatedRequest, res: }; 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); diff --git a/ui/src/components/track.tsx b/ui/src/components/track.tsx index e129b4f..3f2ab56 100644 --- a/ui/src/components/track.tsx +++ b/ui/src/components/track.tsx @@ -6,7 +6,7 @@ import * as Api from "../lib/api" export function TrackItem({ track, selected }: { track: TrackDetails, selected: boolean }) { return <> -
+
{track.image ? ( +
Date: Mon, 16 Feb 2026 23:40:54 +0000 Subject: [PATCH 10/10] simplify code --- README.md | 7 +++-- src/server.ts | 22 ++------------- ui/src/App.tsx | 42 +--------------------------- ui/src/components/analyse-recent.tsx | 11 ++------ ui/src/components/eq-visualizer.tsx | 32 --------------------- ui/src/components/home.tsx | 32 +++++++++++++++------ ui/src/components/nav-user.tsx | 27 +++++++----------- ui/src/lib/api.ts | 7 ++++- 8 files changed, 48 insertions(+), 132 deletions(-) delete mode 100644 ui/src/components/eq-visualizer.tsx diff --git a/README.md b/README.md index 0d2f091..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 diff --git a/src/server.ts b/src/server.ts index ad02ce1..6dfaa1e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -105,24 +105,8 @@ app.get('/api/now-playing', authenticateJWT, async (req: AuthenticatedRequest, r const track = await Spotify.getCurrentlyPlaying(user) if (!track) return res.status(200).json(null) let trackDetails: TrackDetails = { - ...Spotify.convertSpotifyTrackToTrackDetails(track), - loading: true, - } - await Store.pushHistory(user.spotifyId, trackDetails.trackId); - let cached = await Store.getTrack(trackDetails.trackId); - - // Either have filled all the filed or are still waiting for the analysis to finish - if (cached && (Analysis.trackAnalysisFilled(cached) || cached.loading)) { - res.status(200).json(cached); - return; + ...Spotify.convertSpotifyTrackToTrackDetails(track) } - // Still have some fields to fill and we are not waiting for the analysis to finish - if (cached && !(Analysis.trackAnalysisFilled(cached) && !cached.loading)) { - trackDetails = { ...cached, loading: true }; - } - - await Store.updateTrack(trackDetails.trackId, trackDetails); - Analysis.runAnalysis(trackDetails); res.status(200).json(trackDetails); } catch (error: any) { console.error('Error during now playing', error); @@ -148,7 +132,7 @@ 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' }); @@ -159,12 +143,10 @@ app.get('/api/analyse', authenticateJWT, async (req: AuthenticatedRequest, res: track = Spotify.convertSpotifyTrackToTrackDetails(spotifyTrack); await Store.pushHistory(user.spotifyId, track.trackId); } - if (Analysis.trackAnalysisFilled(track)) { res.status(200).json({ message: 'Analysis already completed' }); return; } - const updatedTrack = { ...track, loading: true, 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/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 ( -