Skip to content

Commit d3bf53a

Browse files
authored
Merge pull request #349 from lklynet/feature/tag-search-merged
Merge tag search results and simplify feedback
2 parents d184c8d + 59bac0b commit d3bf53a

5 files changed

Lines changed: 260 additions & 134 deletions

File tree

backend/routes/search.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ router.get("/", noCache, async (req, res) => {
1515
scope = "artist",
1616
limit = 24,
1717
offset = 0,
18-
tagScope = "all",
18+
tagScope = "merged",
1919
releaseTypes = "",
2020
} = req.query;
2121

backend/services/searchService.js

Lines changed: 210 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,136 @@ function normalizeTagArtistItem(artist, tag) {
292292
};
293293
}
294294

295+
function getTagArtistKey(artist) {
296+
const artistId = String(artist?.id || artist?.mbid || "").trim().toLowerCase();
297+
if (artistId) return `id:${artistId}`;
298+
const artistName = String(artist?.name || "").trim().toLowerCase();
299+
return artistName ? `name:${artistName}` : null;
300+
}
301+
302+
function matchesTagSearch(artist, normalizedTag) {
303+
const tags = Array.isArray(artist?.tags) ? artist.tags : [];
304+
const genres = Array.isArray(artist?.genres) ? artist.genres : [];
305+
return [...tags, ...genres].some(
306+
(entry) => normalizeSearchText(entry) === normalizedTag,
307+
);
308+
}
309+
310+
function dedupeTagArtists(artists) {
311+
const seen = new Set();
312+
const output = [];
313+
for (const artist of Array.isArray(artists) ? artists : []) {
314+
const key = getTagArtistKey(artist);
315+
if (!key || seen.has(key)) continue;
316+
seen.add(key);
317+
output.push(artist);
318+
}
319+
return output;
320+
}
321+
322+
function getTagSourceMap(artists) {
323+
const sourceMap = new Map();
324+
for (const artist of Array.isArray(artists) ? artists : []) {
325+
const key = getTagArtistKey(artist);
326+
if (!key || sourceMap.has(key)) continue;
327+
sourceMap.set(key, artist?.tagResultSource || "all");
328+
}
329+
return sourceMap;
330+
}
331+
332+
function normalizeLastfmTagArtist(artist, tag) {
333+
let imageUrl = null;
334+
if (Array.isArray(artist?.image)) {
335+
const img =
336+
artist.image.find((entry) => entry.size === "extralarge") ||
337+
artist.image.find((entry) => entry.size === "large") ||
338+
artist.image.slice(-1)[0];
339+
if (
340+
img?.["#text"] &&
341+
!String(img["#text"]).includes("2a96cbd8b46e442fc41c2b86b821562f")
342+
) {
343+
imageUrl = img["#text"];
344+
}
345+
}
346+
347+
return normalizeTagArtistItem(
348+
{
349+
type: "artist",
350+
id: artist?.mbid || null,
351+
name: artist?.name || "Unknown Artist",
352+
sortName: artist?.name || "Unknown Artist",
353+
image: buildImageProxyUrl(imageUrl) || imageUrl,
354+
imageUrl: buildImageProxyUrl(imageUrl) || imageUrl,
355+
artistType: null,
356+
country: null,
357+
area: null,
358+
begin: null,
359+
end: null,
360+
disambiguation: null,
361+
tags: [tag],
362+
genres: [tag],
363+
inLibrary: false,
364+
score: 0,
365+
tagResultSource: "all",
366+
},
367+
tag,
368+
);
369+
}
370+
371+
async function fetchMergedLastfmTagArtists(tag, limitInt, offsetInt, recommendedItems) {
372+
const pageSize = 50;
373+
const requiredCount = offsetInt + limitInt;
374+
const supplementalItems = [];
375+
const seen = new Set(recommendedItems.map((artist) => getTagArtistKey(artist)).filter(Boolean));
376+
let page = 1;
377+
let exhausted = false;
378+
379+
while (!exhausted && recommendedItems.length + supplementalItems.length < requiredCount) {
380+
const data = await lastfmRequest("tag.getTopArtists", {
381+
tag,
382+
limit: pageSize,
383+
page,
384+
});
385+
const artists = Array.isArray(data?.topartists?.artist)
386+
? data.topartists.artist
387+
: data?.topartists?.artist
388+
? [data.topartists.artist]
389+
: [];
390+
391+
if (artists.length === 0) {
392+
exhausted = true;
393+
break;
394+
}
395+
396+
for (const artist of artists) {
397+
const normalized = normalizeLastfmTagArtist(artist, tag);
398+
const key = getTagArtistKey(normalized);
399+
if (!key || seen.has(key)) continue;
400+
seen.add(key);
401+
supplementalItems.push(normalized);
402+
}
403+
404+
const reportedTotal = Number.parseInt(data?.topartists?.["@attr"]?.total, 10);
405+
const totalPages = Number.isFinite(reportedTotal)
406+
? Math.ceil(reportedTotal / pageSize)
407+
: null;
408+
exhausted =
409+
artists.length < pageSize ||
410+
(Number.isFinite(totalPages) && page >= totalPages);
411+
page += 1;
412+
}
413+
414+
return {
415+
items: [...recommendedItems, ...supplementalItems],
416+
exhausted,
417+
};
418+
}
419+
295420
export async function searchTags(
296421
query,
297422
limit = 24,
298423
offset = 0,
299-
tagScope = "recommended",
424+
tagScope = "merged",
300425
) {
301426
const tag = String(query || "").trim().replace(/^#/, "");
302427
const limitInt = parsePositiveInt(limit, 24);
@@ -312,6 +437,33 @@ export async function searchTags(
312437
};
313438
}
314439

440+
const discoveryCache = getDiscoveryCache();
441+
const tagLower = normalizeSearchText(tag);
442+
const recommendedMatches = dedupeTagArtists(
443+
(discoveryCache.recommendations || [])
444+
.filter((artist) => matchesTagSearch(artist, tagLower))
445+
.map((artist) =>
446+
normalizeTagArtistItem(
447+
{
448+
...artist,
449+
tagResultSource: "recommended",
450+
},
451+
tag,
452+
),
453+
),
454+
);
455+
456+
if (tagScope === "recommended") {
457+
return {
458+
scope: "tag",
459+
query: tag,
460+
count: recommendedMatches.length,
461+
offset: offsetInt,
462+
hasMore: offsetInt + limitInt < recommendedMatches.length,
463+
items: recommendedMatches.slice(offsetInt, offsetInt + limitInt),
464+
};
465+
}
466+
315467
if (tagScope === "all") {
316468
if (getLastfmApiKey()) {
317469
const page = Math.floor(offsetInt / limitInt) + 1;
@@ -326,42 +478,7 @@ export async function searchTags(
326478
? [data.topartists.artist]
327479
: [];
328480
const items = artists
329-
.map((artist) => {
330-
let imageUrl = null;
331-
if (Array.isArray(artist?.image)) {
332-
const img =
333-
artist.image.find((entry) => entry.size === "extralarge") ||
334-
artist.image.find((entry) => entry.size === "large") ||
335-
artist.image.slice(-1)[0];
336-
if (
337-
img?.["#text"] &&
338-
!String(img["#text"]).includes("2a96cbd8b46e442fc41c2b86b821562f")
339-
) {
340-
imageUrl = img["#text"];
341-
}
342-
}
343-
return normalizeTagArtistItem(
344-
{
345-
type: "artist",
346-
id: artist?.mbid || null,
347-
name: artist?.name || "Unknown Artist",
348-
sortName: artist?.name || "Unknown Artist",
349-
image: buildImageProxyUrl(imageUrl) || imageUrl,
350-
imageUrl: buildImageProxyUrl(imageUrl) || imageUrl,
351-
artistType: null,
352-
country: null,
353-
area: null,
354-
begin: null,
355-
end: null,
356-
disambiguation: null,
357-
tags: [tag],
358-
genres: [tag],
359-
inLibrary: false,
360-
score: 0,
361-
},
362-
tag,
363-
);
364-
})
481+
.map((artist) => normalizeLastfmTagArtist(artist, tag))
365482
.filter((artist) => artist.id);
366483

367484
return {
@@ -370,63 +487,85 @@ export async function searchTags(
370487
count:
371488
Number.parseInt(data?.topartists?.["@attr"]?.total, 10) || items.length,
372489
offset: offsetInt,
490+
hasMore:
491+
offsetInt + items.length <
492+
(Number.parseInt(data?.topartists?.["@attr"]?.total, 10) || items.length),
373493
items,
374494
};
375495
}
376496

377-
const discoveryCache = getDiscoveryCache();
378-
const tagLower = normalizeSearchText(tag);
379-
const pool = [
497+
const sourceMap = getTagSourceMap(recommendedMatches);
498+
const poolItems = dedupeTagArtists([
380499
...(Array.isArray(discoveryCache.recommendations)
381500
? discoveryCache.recommendations
382501
: []),
383502
...(Array.isArray(discoveryCache.globalTop) ? discoveryCache.globalTop : []),
384503
...(Array.isArray(discoveryCache.basedOn) ? discoveryCache.basedOn : []),
385-
];
386-
const seen = new Set();
387-
const matches = pool.filter((artist) => {
388-
const artistId = String(artist?.id || artist?.mbid || "").trim().toLowerCase();
389-
const artistName = String(artist?.name || "").trim().toLowerCase();
390-
const key = artistId || artistName;
391-
if (!key || seen.has(key)) return false;
392-
const tags = Array.isArray(artist.tags) ? artist.tags : [];
393-
const genres = Array.isArray(artist.genres) ? artist.genres : [];
394-
const matched = [...tags, ...genres].some(
395-
(entry) => normalizeSearchText(entry) === tagLower,
504+
])
505+
.filter((artist) => matchesTagSearch(artist, tagLower))
506+
.map((artist) =>
507+
normalizeTagArtistItem(
508+
{
509+
...artist,
510+
tagResultSource: sourceMap.get(getTagArtistKey(artist)) || "all",
511+
},
512+
tag,
513+
),
396514
);
397-
if (!matched) return false;
398-
seen.add(key);
399-
return true;
400-
});
401515

402516
return {
403517
scope: "tag",
404518
query: tag,
405-
count: matches.length,
519+
count: poolItems.length,
406520
offset: offsetInt,
407-
items: matches
408-
.slice(offsetInt, offsetInt + limitInt)
409-
.map((artist) => normalizeTagArtistItem(artist, tag)),
521+
hasMore: offsetInt + limitInt < poolItems.length,
522+
items: poolItems.slice(offsetInt, offsetInt + limitInt),
410523
};
411524
}
412525

413-
const discoveryCache = getDiscoveryCache();
414-
const tagLower = normalizeSearchText(tag);
415-
const matches = (discoveryCache.recommendations || []).filter((artist) => {
416-
const tags = Array.isArray(artist.tags) ? artist.tags : [];
417-
const genres = Array.isArray(artist.genres) ? artist.genres : [];
418-
return [...tags, ...genres].some(
419-
(entry) => normalizeSearchText(entry) === tagLower,
526+
if (getLastfmApiKey()) {
527+
const merged = await fetchMergedLastfmTagArtists(
528+
tag,
529+
limitInt,
530+
offsetInt,
531+
recommendedMatches,
532+
);
533+
const items = merged.items.slice(offsetInt, offsetInt + limitInt);
534+
return {
535+
scope: "tag",
536+
query: tag,
537+
count: merged.exhausted
538+
? merged.items.length
539+
: offsetInt + items.length + (merged.items.length > offsetInt + items.length ? 1 : 0),
540+
offset: offsetInt,
541+
hasMore: !merged.exhausted || offsetInt + items.length < merged.items.length,
542+
items,
543+
};
544+
}
545+
546+
const mergedItems = dedupeTagArtists([
547+
...recommendedMatches,
548+
...(Array.isArray(discoveryCache.globalTop) ? discoveryCache.globalTop : []),
549+
...(Array.isArray(discoveryCache.basedOn) ? discoveryCache.basedOn : []),
550+
])
551+
.filter((artist) => matchesTagSearch(artist, tagLower))
552+
.map((artist) =>
553+
normalizeTagArtistItem(
554+
{
555+
...artist,
556+
tagResultSource:
557+
artist.tagResultSource === "recommended" ? "recommended" : "all",
558+
},
559+
tag,
560+
),
420561
);
421-
});
422562

423563
return {
424564
scope: "tag",
425565
query: tag,
426-
count: matches.length,
566+
count: mergedItems.length,
427567
offset: offsetInt,
428-
items: matches
429-
.slice(offsetInt, offsetInt + limitInt)
430-
.map((artist) => normalizeTagArtistItem(artist, tag)),
568+
hasMore: offsetInt + limitInt < mergedItems.length,
569+
items: mergedItems.slice(offsetInt, offsetInt + limitInt),
431570
};
432571
}

0 commit comments

Comments
 (0)