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
66 changes: 62 additions & 4 deletions .tests/discovery/recommendation-pipeline.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildDiscoverySeedList,
finalizeRecommendationAccumulator,
mergeResolvedRecommendations,
rerankRecommendations,
} from "../../backend/services/discoveryRecommendations.js";

test("buildDiscoverySeedList prefers listening-history weight when the same artist exists in library and history", () => {
Expand Down Expand Up @@ -43,7 +44,10 @@ test("buildDiscoverySeedList prefers listening-history weight when the same arti
test("recommendation candidates aggregate across multiple seeds", () => {
const accumulator = new Map();
const existingArtistKeys = new Set();
const profileTagSet = new Set(["dream-pop", "shoegaze"]);
const profileTagWeights = new Map([
["dream-pop", 3],
["shoegaze", 4],
]);

addRecommendationCandidate(accumulator, {
candidate: {
Expand All @@ -57,7 +61,7 @@ test("recommendation candidates aggregate across multiple seeds", () => {
weight: 1.2,
},
sourceTags: ["Dream-Pop", "Indie"],
profileTagSet,
profileTagWeights,
existingArtistKeys,
});

Expand All @@ -73,7 +77,7 @@ test("recommendation candidates aggregate across multiple seeds", () => {
weight: 1.5,
},
sourceTags: ["Shoegaze"],
profileTagSet,
profileTagWeights,
existingArtistKeys,
});

Expand All @@ -85,9 +89,12 @@ test("recommendation candidates aggregate across multiple seeds", () => {
recommendation.sourceArtists.sort(),
["Seed One", "Seed Two"],
);
assert.ok(recommendation.score > 200);
assert.ok(recommendation.scoreTotal > 150);
assert.ok(recommendation.tags.includes("dream-pop"));
assert.ok(recommendation.tags.includes("shoegaze"));
assert.ok(recommendation.matchedTags.includes("dream-pop"));
assert.ok(recommendation.matchedTags.includes("shoegaze"));
assert.ok(recommendation.reasonCodes.length > 0);
});

test("mergeResolvedRecommendations collapses name and mbid variants of the same artist", () => {
Expand Down Expand Up @@ -118,3 +125,54 @@ test("mergeResolvedRecommendations collapses name and mbid variants of the same
["dream-pop", "shoegaze"],
);
});

test("rerankRecommendations can hide items and favor deeper mode diversification", () => {
const recommendations = [
{
id: "55555555-5555-5555-5555-555555555555",
name: "Safe Pick",
matchedTags: ["shoegaze", "dream-pop"],
supportingSeeds: [{ artistName: "Slowdive", weight: 2 }],
scoreSimilarity: 110,
scoreTagAffinity: 30,
scoreSeedCoverage: 20,
scoreNovelty: 8,
scorePopularityPenalty: 12,
scoreTotal: 156,
seedCount: 3,
sourceType: "lastfm",
},
{
id: "66666666-6666-6666-6666-666666666666",
name: "Deeper Pick",
matchedTags: ["shoegaze", "ethereal"],
supportingSeeds: [{ artistName: "Curve", weight: 1.4 }],
scoreSimilarity: 80,
scoreTagAffinity: 26,
scoreSeedCoverage: 14,
scoreNovelty: 26,
scorePopularityPenalty: 4,
scoreTotal: 142,
seedCount: 1,
sourceType: "lastfm",
},
];

const hidden = rerankRecommendations(recommendations, 10, {
discoveryMode: "balanced",
feedback: [
{
id: "hide-safe",
artistId: "55555555-5555-5555-5555-555555555555",
action: "hide_for_now",
},
],
});
assert.equal(hidden.length, 1);
assert.equal(hidden[0].name, "Deeper Pick");

const deeper = rerankRecommendations(recommendations, 2, {
discoveryMode: "deeper",
});
assert.equal(deeper[0].name, "Deeper Pick");
});
10 changes: 8 additions & 2 deletions backend/config/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,17 @@ export const defaultData = {
username: "",
discoveryPeriod: "1month",
discoveryAutoRefreshHours: 168,
discoveryRecommendationsPerRefresh: 100,
discoveryRecommendationsPerRefresh: 200,
discoveryMode: "balanced",
},
slskd: { url: "", apiKey: "" },
soulseek: { username: "", password: "" },
ticketmaster: { apiKey: "", searchRadiusMiles: 50 },
ticketmaster: {
apiKey: "",
searchRadiusMiles: 50,
localDiscoveryIncludeRecommendations: true,
localDiscoveryIncludeTrending: true,
},
lidarr: {
url: "",
externalUrl: "",
Expand Down
138 changes: 137 additions & 1 deletion backend/routes/discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import {
updateUserDiscoveryCache,
getUserDiscoveryCacheStaleness,
getDiscoveryAutoRefreshHours,
getDiscoveryMode,
getDiscoveryFeedback,
addDiscoveryFeedback,
removeDiscoveryFeedback,
rerankCachedRecommendations,
getLocalDiscoveryPreferences,
} from "../services/discoveryService.js";
import {
lastfmRequest,
Expand Down Expand Up @@ -325,6 +331,8 @@ router.get("/", requireAuth, async (req, res) => {

let { recommendations, globalTop, basedOn, topTags, topGenres, lastUpdated } =
discoveryCache;
const feedback = getDiscoveryFeedback(req.user?.id || "global");
const discoveryMode = getDiscoveryMode();

const existingArtistIds = new Set(
libraryArtists
Expand Down Expand Up @@ -357,6 +365,11 @@ router.get("/", requireAuth, async (req, res) => {
globalTop = applyBlocklistToArtistCollection(globalTop, blocklist);
topTags = applyBlocklistToTagList(topTags, blocklist);
topGenres = applyBlocklistToTagList(topGenres, blocklist);
recommendations = rerankCachedRecommendations({
recommendations,
feedback,
discoveryMode,
});

const parsedLastUpdated = lastUpdated ? new Date(lastUpdated).getTime() : 0;
const isStale =
Expand Down Expand Up @@ -404,6 +417,7 @@ router.get("/", requireAuth, async (req, res) => {
isUpdating,
stale: isStale,
configured: true,
discoveryMode,
});
});

Expand Down Expand Up @@ -655,6 +669,7 @@ router.get("/nearby-shows", requireAuth, async (req, res) => {
counts: {
libraryArtists: 0,
matchedLibraryShows: 0,
matchedRecommendedShows: 0,
},
});
}
Expand All @@ -665,14 +680,41 @@ router.get("/nearby-shows", requireAuth, async (req, res) => {
const configuredRadius = Number(
settings.integrations?.ticketmaster?.searchRadiusMiles,
);
const localDiscoveryPreferences = getLocalDiscoveryPreferences();
const radiusMiles = Number.isFinite(configuredRadius)
? Math.max(5, Math.min(250, Math.floor(configuredRadius)))
: undefined;
const libraryArtists = await libraryManager.getAllArtists();
const reqUser = userOps.getUserById(req.user.id);
const userCacheNamespace = getListenHistoryCacheNamespace(
getListenHistoryProfile(reqUser || {}),
);
const discoveryCache = getDiscoveryCache(userCacheNamespace);
const feedback = getDiscoveryFeedback(req.user?.id || "global");
const blocklist = getStoredBlocklist();
const recommendedArtists = localDiscoveryPreferences.includeRecommendations
? rerankCachedRecommendations({
recommendations: applyBlocklistToArtistCollection(
discoveryCache.recommendations || [],
blocklist,
),
feedback,
discoveryMode: getDiscoveryMode(),
limit: 24,
})
: [];
const trendingArtists = localDiscoveryPreferences.includeTrending
? applyBlocklistToArtistCollection(
discoveryCache.globalTop || [],
blocklist,
).slice(0, 18)
: [];
const nearbyShows = await getNearbyShows({
req,
zipCode,
libraryArtists,
recommendedArtists,
trendingArtists,
limit,
radiusMiles,
});
Expand All @@ -692,6 +734,7 @@ router.get("/nearby-shows", requireAuth, async (req, res) => {

router.get("/preferences", requireAuth, (req, res) => {
const blocklist = getStoredBlocklist();
const localDiscoveryPreferences = getLocalDiscoveryPreferences();
res.json({
excludedGenres: blocklist.tags,
excludedTags: blocklist.tags,
Expand All @@ -704,6 +747,10 @@ router.get("/preferences", requireAuth, (req, res) => {
includeFromLastfm: true,
includeFromLibrary: true,
includeTrending: true,
discoveryMode: getDiscoveryMode(),
localDiscoveryIncludeRecommendations:
localDiscoveryPreferences.includeRecommendations,
localDiscoveryIncludeTrending: localDiscoveryPreferences.includeTrending,
});
});

Expand All @@ -723,6 +770,28 @@ router.post("/preferences", requireAuth, (req, res) => {
artists,
tags: tags.length > 0 ? tags : undefined,
});
const currentSettings = dbOps.getSettings();
const nextSettings = {
...currentSettings,
integrations: {
...(currentSettings.integrations || {}),
lastfm: {
...(currentSettings.integrations?.lastfm || {}),
discoveryMode:
updates.discoveryMode === "safer" || updates.discoveryMode === "deeper"
? updates.discoveryMode
: "balanced",
},
ticketmaster: {
...(currentSettings.integrations?.ticketmaster || {}),
localDiscoveryIncludeRecommendations:
updates.localDiscoveryIncludeRecommendations !== false,
localDiscoveryIncludeTrending:
updates.localDiscoveryIncludeTrending !== false,
},
},
};
dbOps.updateSettings(nextSettings);

res.json({
success: true,
Expand All @@ -733,6 +802,13 @@ router.post("/preferences", requireAuth, (req, res) => {
artistId: artist.mbid || artist.name,
artistName: artist.name || artist.mbid || "",
})),
discoveryMode: nextSettings.integrations?.lastfm?.discoveryMode || "balanced",
localDiscoveryIncludeRecommendations:
nextSettings.integrations?.ticketmaster?.localDiscoveryIncludeRecommendations !==
false,
localDiscoveryIncludeTrending:
nextSettings.integrations?.ticketmaster?.localDiscoveryIncludeTrending !==
false,
},
});
} catch (error) {
Expand All @@ -748,16 +824,68 @@ router.post("/preferences/reset", requireAuth, (req, res) => {
artists: [],
tags: [],
});
const currentSettings = dbOps.getSettings();
dbOps.updateSettings({
...currentSettings,
integrations: {
...(currentSettings.integrations || {}),
lastfm: {
...(currentSettings.integrations?.lastfm || {}),
discoveryMode: "balanced",
},
ticketmaster: {
...(currentSettings.integrations?.ticketmaster || {}),
localDiscoveryIncludeRecommendations: true,
localDiscoveryIncludeTrending: true,
},
},
});
res.json({
success: true,
preferences: {
excludedGenres: blocklist.tags,
excludedTags: blocklist.tags,
excludedArtists: [],
discoveryMode: "balanced",
localDiscoveryIncludeRecommendations: true,
localDiscoveryIncludeTrending: true,
},
});
});

router.get("/feedback", requireAuth, (req, res) => {
res.json({
feedback: getDiscoveryFeedback(req.user?.id || "global"),
});
});

router.post("/feedback", requireAuth, (req, res) => {
try {
const feedback = addDiscoveryFeedback(req.user?.id || "global", req.body || {});
res.json({
success: true,
feedback,
feedbackList: getDiscoveryFeedback(req.user?.id || "global"),
});
} catch (error) {
res.status(400).json({
error: "Failed to save discovery feedback",
message: error.message,
});
}
});

router.delete("/feedback/:id", requireAuth, (req, res) => {
const feedbackList = removeDiscoveryFeedback(
req.user?.id || "global",
req.params.id,
);
res.json({
success: true,
feedbackList,
});
});

router.post("/preferences/exclude-genre", requireAuth, (req, res) => {
try {
const { genre } = req.body;
Expand Down Expand Up @@ -864,10 +992,12 @@ router.delete(
},
);

router.get("/filtered", async (req, res) => {
router.get("/filtered", requireAuth, async (req, res) => {
try {
const discoveryCache = getDiscoveryCache();
const blocklist = getStoredBlocklist();
const feedback = getDiscoveryFeedback(req.user?.id || "global");
const discoveryMode = getDiscoveryMode();
let recommendations = applyBlocklistToArtistCollection(
discoveryCache.recommendations || [],
blocklist,
Expand All @@ -888,6 +1018,11 @@ router.get("/filtered", async (req, res) => {
(artist) => !existingArtistIds.has(artist.id),
);
globalTop = globalTop.filter((artist) => !existingArtistIds.has(artist.id));
recommendations = rerankCachedRecommendations({
recommendations,
feedback,
discoveryMode,
});

res.json({
recommendations,
Expand All @@ -904,6 +1039,7 @@ router.get("/filtered", async (req, res) => {
genres: blocklist.tags.length,
artists: blocklist.artists.length,
},
discoveryMode,
});
} catch (error) {
res.status(500).json({
Expand Down
Loading