Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements

Major Features:
- Multi-source download system (Soulseek/Lidarr with fallback)
- Configurable enrichment speed control (1-5x)
- Mobile touch drag support for seek sliders
- iOS PWA media controls (Control Center, Lock Screen)
- Artist name alias resolution via Last.fm
- Circuit breaker pattern for audio analysis

Critical Fixes:
- Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM)
- Discovery system race conditions and import failures
- Radio decade categorization using originalYear
- LastFM API response normalization
- Mood bucket infinite loop prevention

Security:
- Bull Board admin authentication
- Lidarr webhook signature verification
- JWT token expiration and refresh
- Encryption key validation on startup

Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
This commit is contained in:
Your Name
2026-01-06 20:07:33 -06:00
parent 8fe151a0d1
commit cc8d0f6969
242 changed files with 20562 additions and 7725 deletions
+67 -7
View File
@@ -6,6 +6,7 @@
* instant mood mix generation through simple database lookups.
*/
import { logger } from "../utils/logger";
import { prisma } from "../utils/db";
// Mood configuration with scoring rules
@@ -16,6 +17,7 @@ export const MOOD_CONFIG = {
name: "Happy & Upbeat",
color: "from-yellow-400 to-orange-500",
icon: "Smile",
moodTagKeywords: ["happy", "upbeat", "cheerful", "joyful", "positive"],
// Primary: ML mood prediction
primary: { moodHappy: { min: 0.5 }, moodSad: { max: 0.4 } },
// Fallback: basic audio features
@@ -25,6 +27,7 @@ export const MOOD_CONFIG = {
name: "Melancholic",
color: "from-blue-600 to-indigo-700",
icon: "CloudRain",
moodTagKeywords: ["sad", "melancholic", "melancholy", "dark", "somber"],
primary: { moodSad: { min: 0.5 }, moodHappy: { max: 0.4 } },
fallback: { valence: { max: 0.35 }, keyScale: "minor" },
},
@@ -32,6 +35,7 @@ export const MOOD_CONFIG = {
name: "Chill & Relaxed",
color: "from-teal-400 to-cyan-500",
icon: "Wind",
moodTagKeywords: ["relaxed", "chill", "calm", "mellow"],
primary: { moodRelaxed: { min: 0.5 }, moodAggressive: { max: 0.3 } },
fallback: { energy: { max: 0.5 }, arousal: { max: 0.5 } },
},
@@ -39,6 +43,7 @@ export const MOOD_CONFIG = {
name: "High Energy",
color: "from-red-500 to-orange-600",
icon: "Zap",
moodTagKeywords: ["energetic", "powerful", "exciting"],
primary: { arousal: { min: 0.6 }, energy: { min: 0.7 } },
fallback: { bpm: { min: 120 }, energy: { min: 0.7 } },
},
@@ -46,6 +51,7 @@ export const MOOD_CONFIG = {
name: "Dance Party",
color: "from-pink-500 to-rose-600",
icon: "PartyPopper",
moodTagKeywords: ["party", "danceable", "groovy"],
primary: { moodParty: { min: 0.5 }, danceability: { min: 0.6 } },
fallback: { danceability: { min: 0.7 }, energy: { min: 0.6 } },
},
@@ -53,6 +59,7 @@ export const MOOD_CONFIG = {
name: "Focus Mode",
color: "from-purple-600 to-violet-700",
icon: "Brain",
moodTagKeywords: ["instrumental"],
primary: { instrumentalness: { min: 0.5 }, moodRelaxed: { min: 0.3 } },
fallback: {
instrumentalness: { min: 0.5 },
@@ -63,6 +70,7 @@ export const MOOD_CONFIG = {
name: "Deep Feels",
color: "from-gray-700 to-slate-800",
icon: "Moon",
moodTagKeywords: ["sad", "melancholic", "emotional", "dark"],
primary: { moodSad: { min: 0.4 }, valence: { max: 0.4 } },
fallback: { valence: { max: 0.35 }, keyScale: "minor" },
},
@@ -70,6 +78,7 @@ export const MOOD_CONFIG = {
name: "Intense",
color: "from-red-700 to-gray-900",
icon: "Flame",
moodTagKeywords: ["aggressive", "angry"],
primary: { moodAggressive: { min: 0.5 } },
fallback: { energy: { min: 0.8 }, arousal: { min: 0.7 } },
},
@@ -77,6 +86,7 @@ export const MOOD_CONFIG = {
name: "Acoustic Vibes",
color: "from-amber-500 to-yellow-600",
icon: "Guitar",
moodTagKeywords: ["acoustic"],
primary: { moodAcoustic: { min: 0.5 }, moodElectronic: { max: 0.4 } },
fallback: {
acousticness: { min: 0.6 },
@@ -123,6 +133,7 @@ interface TrackWithAnalysis {
instrumentalness: number | null;
bpm: number | null;
keyScale: string | null;
moodTags: string[];
}
export class MoodBucketService {
@@ -153,11 +164,12 @@ export class MoodBucketService {
instrumentalness: true,
bpm: true,
keyScale: true,
moodTags: true,
},
});
if (!track || track.analysisStatus !== "completed") {
console.log(
logger.debug(
`[MoodBucket] Track ${trackId} not analyzed yet, skipping`
);
return [];
@@ -199,7 +211,7 @@ export class MoodBucketService {
.filter(([_, score]) => score > 0)
.map(([mood]) => mood);
console.log(
logger.debug(
`[MoodBucket] Track ${trackId} assigned to moods: ${
assignedMoods.join(", ") || "none"
}`
@@ -226,6 +238,16 @@ export class MoodBucketService {
acoustic: 0,
};
// Check if we have individual mood fields OR moodTags
const hasIndividualMoods = track.moodHappy !== null || track.moodSad !== null;
const hasMoodTags = track.moodTags && track.moodTags.length > 0;
// If we have moodTags but no individual mood fields, parse moodTags
if (!hasIndividualMoods && hasMoodTags) {
return this.calculateMoodScoresFromTags(track.moodTags);
}
// Otherwise use original logic
for (const [mood, config] of Object.entries(MOOD_CONFIG)) {
const rules = isEnhanced ? config.primary : config.fallback;
const score = this.evaluateMoodRules(track, rules);
@@ -235,6 +257,43 @@ export class MoodBucketService {
return scores;
}
/**
* Calculate mood scores from moodTags array
* Used when individual mood fields are not populated
*/
private calculateMoodScoresFromTags(moodTags: string[]): Record<MoodType, number> {
const scores: Record<MoodType, number> = {
happy: 0,
sad: 0,
chill: 0,
energetic: 0,
party: 0,
focus: 0,
melancholy: 0,
aggressive: 0,
acoustic: 0,
};
const normalizedTags = moodTags.map(tag => tag.toLowerCase());
for (const [mood, config] of Object.entries(MOOD_CONFIG)) {
const keywords = config.moodTagKeywords;
let matchCount = 0;
for (const keyword of keywords) {
if (normalizedTags.includes(keyword)) {
matchCount++;
}
}
if (matchCount > 0) {
scores[mood as MoodType] = Math.min(1.0, 0.3 + (matchCount - 1) * 0.2);
}
}
return scores;
}
/**
* Evaluate mood rules against track features
* Returns a score 0-1 based on how well the track matches the rules
@@ -380,7 +439,7 @@ export class MoodBucketService {
});
if (moodBuckets.length < 8) {
console.log(
logger.debug(
`[MoodBucket] Not enough tracks for mood ${mood}: ${moodBuckets.length}`
);
return null;
@@ -465,7 +524,7 @@ export class MoodBucketService {
},
});
console.log(
logger.debug(
`[MoodBucket] Saved ${mood} mix for user ${userId} (${mix.trackCount} tracks)`
);
@@ -532,7 +591,7 @@ export class MoodBucketService {
let assigned = 0;
let skip = 0;
console.log("[MoodBucket] Starting backfill of all analyzed tracks...");
logger.debug("[MoodBucket] Starting backfill of all analyzed tracks...");
while (true) {
const tracks = await prisma.track.findMany({
@@ -555,6 +614,7 @@ export class MoodBucketService {
instrumentalness: true,
bpm: true,
keyScale: true,
moodTags: true,
},
skip,
take: batchSize,
@@ -601,12 +661,12 @@ export class MoodBucketService {
}
skip += batchSize;
console.log(
logger.debug(
`[MoodBucket] Backfill progress: ${processed} tracks processed, ${assigned} mood assignments`
);
}
console.log(
logger.debug(
`[MoodBucket] Backfill complete: ${processed} tracks processed, ${assigned} mood assignments`
);
return { processed, assigned };