diff --git a/data-plane/routes/sounds.ts b/data-plane/routes/sounds.ts index 3caabd4..f4f60a8 100644 --- a/data-plane/routes/sounds.ts +++ b/data-plane/routes/sounds.ts @@ -39,22 +39,38 @@ export class Sounds { if (!isNaN(parsed) && parsed > 0) skip = parsed; } - const audios = await this.db.models.Audio.find({ - $text: { $search: cleanedTerm }, - }) - .sort({ - score: { $meta: "textScore" }, - useCount: -1, - createdAt: -1, + const pageEnd = skip + limit; + const fetchLimit = pageEnd + 1; + const matchingAuthorDids = await this.findMatchingSoundAuthorDids( + cleanedTerm, + ); + + const [textAudios, authorAudios] = await Promise.all([ + this.db.models.Audio.find({ + $text: { $search: cleanedTerm }, }) - .skip(skip) - .limit(limit) - .lean(); - - const transformedAudios = audios.map(toSoundItem); + .sort({ + score: { $meta: "textScore" }, + useCount: -1, + createdAt: -1, + }) + .limit(fetchLimit) + .lean(), + matchingAuthorDids.length > 0 + ? this.db.models.Audio.find({ + authorDid: { $in: matchingAuthorDids }, + }) + .sort({ useCount: -1, createdAt: -1 }) + .limit(fetchLimit) + .lean() + : Promise.resolve([]), + ]); + + const audios = dedupeAudios([...textAudios, ...authorAudios]); + const transformedAudios = audios.slice(skip, pageEnd).map(toSoundItem); let nextCursor: string | undefined; - if (transformedAudios.length === limit) { + if (audios.length > pageEnd) { nextCursor = (skip + limit).toString(); } @@ -64,6 +80,33 @@ export class Sounds { }; } + private async findMatchingSoundAuthorDids(term: string): Promise { + const cleanedTerm = term.replace(/^@/g, ""); + if (!cleanedTerm) return []; + + const handlePrefix = cleanedTerm.toLowerCase(); + const handleRangeEnd = `${handlePrefix}\uffff`; + + const [matchingActors, matchingProfiles] = await Promise.all([ + this.db.models.Actor.find({ + handle: { + $gte: handlePrefix, + $lt: handleRangeEnd, + }, + }).select("did -_id").lean(), + this.db.models.Profile.find({ + $text: { $search: cleanedTerm }, + }).select("authorDid -_id").lean(), + ]); + + return Array.from( + new Set([ + ...matchingActors.map((actor) => actor.did), + ...matchingProfiles.map((profile) => profile.authorDid), + ]), + ); + } + /** * Get audios by URIs */ @@ -218,3 +261,14 @@ const toSoundItem = (audio: { indexedAt: audio.indexedAt, sortAt: compositeTime(audio.createdAt, audio.indexedAt) || audio.createdAt, }); + +const dedupeAudios = (audios: T[]): T[] => { + const seen = new Set(); + const result: T[] = []; + for (const audio of audios) { + if (seen.has(audio.uri)) continue; + seen.add(audio.uri); + result.push(audio); + } + return result; +}; diff --git a/tests/sounds_test.ts b/tests/sounds_test.ts index ba9e4d2..c3149f8 100644 --- a/tests/sounds_test.ts +++ b/tests/sounds_test.ts @@ -29,6 +29,7 @@ Deno.test({ try { await ctx.db.models.Audio.init(); + await ctx.db.models.Profile.init(); const nowIso = new Date().toISOString(); const authorDid = TEST_USERS[1].did; @@ -155,6 +156,30 @@ Deno.test({ assertEquals(body.audios[0].uri, chillUri); }); + await t.step("searches sounds by author handle", async () => { + const res = await app.request( + "/xrpc/so.sprk.sound.searchAudios?q=@sound-author", + ); + assertEquals(res.status, 200); + + const body = await res.json() as SearchAudiosOutput; + assertEquals(body.audios.length, 2); + assertEquals(body.audios[0].uri, chillUri); + assertEquals(body.audios[1].uri, summerUri); + }); + + await t.step("searches sounds by author display name", async () => { + const res = await app.request( + "/xrpc/so.sprk.sound.searchAudios?q=author", + ); + assertEquals(res.status, 200); + + const body = await res.json() as SearchAudiosOutput; + assertEquals(body.audios.length, 2); + assertEquals(body.audios[0].uri, chillUri); + assertEquals(body.audios[1].uri, summerUri); + }); + await t.step("returns an empty result for blank queries", async () => { const res = await app.request( `/xrpc/so.sprk.sound.searchAudios?q=${encodeURIComponent(" ")}`,