Object with advanced artist insights and recommendations
export async function calculateArtistInsights() {
try {
const statistics = await getStatistics();
const artistMetrics = await aggregateArtistSkipMetrics();
const timePatterns = await analyzeTimeBasedPatterns();
// Initialize insights object
const insights = {
mostCompatibleArtists: [] as Array<{
artistName: string;
compatibility: number;
}>,
leastCompatibleArtists: [] as Array<{
artistName: string;
compatibility: number;
}>,
listeningTrends: {} as Record<
string,
{ trend: "increasing" | "decreasing" | "stable"; changeRate: number }
>,
genreAffinities: {} as Record<string, number>,
timeBasedPreferences: {} as Record<
string,
{ preferredHours: number[]; avoidedHours: number[] }
>,
recommendedExploration: [] as Array<{
artistName: string;
reason: string;
}>,
};
// Only proceed if we have enough data for meaningful insights
if (
!statistics ||
!artistMetrics ||
Object.keys(artistMetrics).length < 5
) {
console.log("Not enough artist data for meaningful insights");
return insights;
}
// Calculate artist compatibility based on skip rates and listening time
const compatibilityScores = Object.entries(artistMetrics)
.map(([, metrics]) => {
// Only consider artists with enough data
if (metrics.uniqueTracksSkipped.length < 3) return null;
// Compatibility score based on inverse of skip ratio and listening time
const skipFactor = 1 - (metrics.skipRatio || 0); // Higher is better
const playFactor = metrics.uniqueTracksSkipped.length / 20; // Normalize, higher is better
const compatibility = skipFactor * 0.7 + playFactor * 0.3; // Weighted score
return {
artistName: metrics.artistName,
compatibility: Math.min(compatibility, 1), // Cap at 1.0
};
})
.filter(Boolean) as Array<{ artistName: string; compatibility: number }>;
// Sort and get most/least compatible artists
compatibilityScores.sort((a, b) => b.compatibility - a.compatibility);
insights.mostCompatibleArtists = compatibilityScores.slice(0, 5);
insights.leastCompatibleArtists = [...compatibilityScores]
.reverse()
.slice(0, 5);
// Analyze listening trends over time
const artistListenHistory: Record<string, Record<string, number>> = {};
// Group by month for trend analysis
if (statistics.dailyMetrics) {
Object.entries(statistics.dailyMetrics).forEach(([dateStr, metrics]) => {
const month = dateStr.substring(0, 7); // YYYY-MM
const artists = Array.isArray(metrics.uniqueArtists)
? metrics.uniqueArtists
: Array.from(metrics.uniqueArtists as Set<string>);
artists.forEach((artistId) => {
const artistData = statistics.artistMetrics[artistId];
if (!artistData) return;
// Initialize artist history if needed
if (!artistListenHistory[artistData.name]) {
artistListenHistory[artistData.name] = {};
}
// Count listens by month
artistListenHistory[artistData.name][month] =
(artistListenHistory[artistData.name][month] || 0) + 1;
});
});
}
// Calculate trends based on month-to-month changes
Object.entries(artistListenHistory).forEach(([artistName, monthData]) => {
const months = Object.keys(monthData).sort();
if (months.length < 2) return; // Need at least 2 months for a trend
// Get last three months if available
const recentMonths = months.slice(-3);
const values = recentMonths.map((m) => monthData[m] || 0);
// Simple trend detection
if (values.length >= 2) {
const latestValue = values[values.length - 1];
const previousValue = values[values.length - 2];
const changeRate =
previousValue === 0
? 0
: (latestValue - previousValue) / previousValue;
let trend: "increasing" | "decreasing" | "stable";
if (changeRate > 0.2) trend = "increasing";
else if (changeRate < -0.2) trend = "decreasing";
else trend = "stable";
insights.listeningTrends[artistName] = { trend, changeRate };
}
});
// Analyze time-based preferences for artists
if (timePatterns && timePatterns.patternsByArtist) {
Object.entries(timePatterns.patternsByArtist).forEach(
([artist, pattern]) => {
const hourlyDistribution = pattern.hourlyDistribution;
if (!hourlyDistribution || hourlyDistribution.length !== 24) return;
// Calculate average hourly plays
const avg =
hourlyDistribution.reduce((sum, count) => sum + count, 0) / 24;
// Find preferred and avoided hours
const preferredHours = hourlyDistribution
.map((count, hour) => ({ hour, count }))
.filter((x) => x.count > avg * 1.5 && x.count >= 3)
.map((x) => x.hour);
const avoidedHours = hourlyDistribution
.map((count, hour) => ({ hour, count }))
.filter(
(x) =>
x.count < avg * 0.5 && artist.toLowerCase() in artistMetrics,
)
.map((x) => x.hour);
insights.timeBasedPreferences[artist] = {
preferredHours,
avoidedHours,
};
},
);
}
// Generate exploration recommendations based on listening patterns
const topArtists = Object.entries(artistMetrics)
.sort(
(a, b) =>
b[1].uniqueTracksSkipped.length - a[1].uniqueTracksSkipped.length,
)
.slice(0, 10)
.map(([, data]) => data.artistName);
// Create simple artist recommendations based on complementary patterns
topArtists.forEach((artist) => {
// Skip artists with high skip rates
const artistData = Object.values(artistMetrics).find(
(a) => a.artistName === artist,
);
if (!artistData || artistData.skipRatio > 0.7) return;
// Check time patterns
const timePreference = insights.timeBasedPreferences[artist];
if (!timePreference) return;
// Recommend artists with similar time preferences
Object.entries(insights.timeBasedPreferences)
.filter(([otherArtist]) => otherArtist !== artist)
.forEach(([otherArtist, otherPattern]) => {
// Check if time preferences overlap
const hasOverlap = timePreference.preferredHours.some((hour) =>
otherPattern.preferredHours.includes(hour),
);
if (hasOverlap) {
insights.recommendedExploration.push({
artistName: otherArtist,
reason: `Similar listening time preferences to ${artist}`,
});
}
});
});
// Deduplicate recommendations
insights.recommendedExploration = [
...new Map(
insights.recommendedExploration.map((item) => [item.artistName, item]),
).values(),
].slice(0, 5); // Limit to 5 recommendations
// Save insights to file for later use
const insightsFilePath = join(
ensureStatisticsDir(),
"artist_insights.json",
);
writeJsonSync(insightsFilePath, insights, { spaces: 2 });
return insights;
} catch (error) {
console.error("Error calculating artist insights:", error);
return null;
}
}
Calculates advanced artist-level insights beyond basic metrics