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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
REDIRECT_URI=
DOMAIN=
GOOGLE_GENERATIVE_AI_API_KEY=
REDIS_URL=
6 changes: 2 additions & 4 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
165 changes: 95 additions & 70 deletions src/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,95 +4,120 @@ 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 = {
...track,
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."),
})
})
// 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 !== '';
};
16 changes: 14 additions & 2 deletions src/consts.ts
Original file line number Diff line number Diff line change
@@ -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'
export const cookieName = 'auth_token'

export const defaultAnalysis: AnalysisData = {
trackAnalysis: undefined,
albumAnalysis: undefined,
personnelAndPlaces: undefined,
artistBiography: undefined,
culturalContext: undefined,
recommendations: undefined,
tags: [],
Comment thread
ruarim marked this conversation as resolved.
}
39 changes: 18 additions & 21 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/spotify.ts
Original file line number Diff line number Diff line change
@@ -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<SpotifyTokenResponse> => {
const params = new URLSearchParams({
Expand Down Expand Up @@ -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,
};
}

Expand Down
Loading