From 0531a1f5c034efdc1b98223e72d2e211ff0be034 Mon Sep 17 00:00:00 2001 From: Yuri Tatishchev Date: Fri, 6 Feb 2026 08:20:42 -0800 Subject: [PATCH] songs page init --- src/lib/db/client-db/queries.ts | 119 ++++++++++++- src/lib/utils/amq/index.ts | 1 + src/lib/utils/amq/song-utils.ts | 20 +++ src/routes/songs/+page.svelte | 303 ++++++++++++++++++++++++++++++++ src/routes/songs/+page.ts | 78 ++++++++ 5 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 src/lib/utils/amq/song-utils.ts create mode 100644 src/routes/songs/+page.svelte create mode 100644 src/routes/songs/+page.ts diff --git a/src/lib/db/client-db/queries.ts b/src/lib/db/client-db/queries.ts index b947b78..2b68cff 100644 --- a/src/lib/db/client-db/queries.ts +++ b/src/lib/db/client-db/queries.ts @@ -1,4 +1,14 @@ -import { desc, eq, inArray, like } from "drizzle-orm"; +import { + and, + between, + desc, + eq, + gte, + inArray, + like, + lte, + or, +} from "drizzle-orm"; import { anime, animeSongLinks, artists, groups, songs } from "$lib/db/schema"; import type { ClientDb } from "./index"; @@ -183,6 +193,113 @@ export async function getSongsForMalAnimeIds( ); } +// Define interfaces for filters +export interface SongFilters { + songName?: string; + artistName?: string; + animeName?: string; // Searches mainName, mainNameEn, mainNameJa + songTypes?: number[]; // 1: OP, 2: ED, 3: INS + globalPercentMin?: number; // 0-100 + globalPercentMax?: number; // 0-100 + category?: number; // 0: none, 1: instrumental, 2: chanting, 3: character, 4: standard +} + +export async function getSongsWithFilters( + db: ClientDb, + filters: SongFilters, + limit = DEFAULT_LIST_LIMIT, +) { + const safeLimit = clampLimit(limit); + const { + songName, + artistName, + animeName, + songTypes, + globalPercentMin, + globalPercentMax, + category, + } = filters; + + const query = db + .select({ + annSongId: songs.annSongId, + songName: songs.name, + fileName: songs.fileName, + globalPercent: songs.globalPercent, + category: songs.category, + + type: animeSongLinks.type, + number: animeSongLinks.number, + + animeAnnId: anime.annId, + animeMainName: anime.mainName, + animeMainNameEn: anime.mainNameEn, + animeMainNameJa: anime.mainNameJa, + + artistName: artists.name, + groupName: groups.name, + }) + .from(songs) + .leftJoin(artists, eq(artists.songArtistId, songs.songArtistId)) + .leftJoin(groups, eq(groups.songGroupId, songs.songGroupId)) + .innerJoin(animeSongLinks, eq(animeSongLinks.annSongId, songs.annSongId)) + .innerJoin(anime, eq(anime.annId, animeSongLinks.annId)) + .limit(safeLimit); + + const conditions = []; + + if (songName) { + conditions.push(like(songs.name, `%${songName}%`)); + } + + if (artistName) { + // Search artistName OR groupName + const artistPattern = `%${artistName}%`; + conditions.push( + or(like(artists.name, artistPattern), like(groups.name, artistPattern)), + ); + } + + if (animeName) { + // Search mainName, mainNameEn, or mainNameJa + const animePattern = `%${animeName}%`; + conditions.push( + or( + like(anime.mainName, animePattern), + like(anime.mainNameEn, animePattern), + like(anime.mainNameJa, animePattern), + ), + ); + } + + if (songTypes && songTypes.length > 0) { + conditions.push(inArray(animeSongLinks.type, songTypes)); + } + + if (globalPercentMin !== undefined && globalPercentMax !== undefined) { + conditions.push( + between(songs.globalPercent, globalPercentMin, globalPercentMax), + ); + } else if (globalPercentMin !== undefined) { + conditions.push(gte(songs.globalPercent, globalPercentMin)); + } else if (globalPercentMax !== undefined) { + conditions.push(lte(songs.globalPercent, globalPercentMax)); + } + + if (category !== undefined) { + conditions.push(eq(songs.category, category)); + } + + if (conditions.length > 0) { + query.where(and(...conditions)); + } + + // Order by song name for now, can add more sophisticated sorting later + query.orderBy(songs.name); + + return query.execute(); +} + function clampLimit(limit: number) { const n = Number(limit); if (!Number.isFinite(n)) return DEFAULT_LIST_LIMIT; diff --git a/src/lib/utils/amq/index.ts b/src/lib/utils/amq/index.ts index 5fe7a46..0f5dc1f 100644 --- a/src/lib/utils/amq/index.ts +++ b/src/lib/utils/amq/index.ts @@ -1 +1,2 @@ export * from "./season"; +export * from "./song-utils"; diff --git a/src/lib/utils/amq/song-utils.ts b/src/lib/utils/amq/song-utils.ts new file mode 100644 index 0000000..8cfcd95 --- /dev/null +++ b/src/lib/utils/amq/song-utils.ts @@ -0,0 +1,20 @@ +export const SongTypeMap: Record = { + OP: 1, + ED: 2, + INS: 3, +}; + +// Map number back to string for displaying in UI +export const SongTypeReverseMap: Record = { + 1: "OP", + 2: "ED", + 3: "INS", +}; + +export const SongCategoryMap: Record = { + 0: "None", + 1: "Instrumental", + 2: "Chanting", + 3: "Character", + 4: "Standard", +}; diff --git a/src/routes/songs/+page.svelte b/src/routes/songs/+page.svelte new file mode 100644 index 0000000..b9c4ac6 --- /dev/null +++ b/src/routes/songs/+page.svelte @@ -0,0 +1,303 @@ + + +

Songs Search

+ +

+ {#if !clientDb} + Loading DB... + {/if} +

+ +
{ + e.preventDefault(); + applyFilters(); + }} +> +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ {#each [1, 2, 3] as typeNum} + + {/each} +
+
+ +
+ +
+ + +
+
+ +
+ + +
+
+ +
+ +
+
+ +{#if data.songRows.length > 0} +
+ + + + + {#if tracksFromResults.length !== data.songRows.length} + + ({tracksFromResults.length} playable) + + {/if} +
+ +

Songs

+ +
    + {#each data.songRows as r (String(r.annSongId) + "-" + String(r.animeAnnId) + "-" + String(r.type) + "-" + String(r.number))} +
  • + +
  • + {/each} +
+{:else if Object.values(form).some((val) => (Array.isArray(val) && val.length > 0) || (typeof val === "string" && val.trim() !== "") || typeof val === "number")} +

+ No songs found matching your criteria. +

+{:else} +

+ Use the filters above to search for songs. +

+{/if} + +{#if !browser} + Loading stuff... +{/if} diff --git a/src/routes/songs/+page.ts b/src/routes/songs/+page.ts new file mode 100644 index 0000000..fd249b3 --- /dev/null +++ b/src/routes/songs/+page.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; +import type { SongFilters } from "$lib/db/client-db"; +import { db, ensureSeeded, getSongsWithFilters } from "$lib/db/client-db"; +import { + SongCategoryMap, + SongTypeMap, + SongTypeReverseMap, +} from "$lib/utils/amq"; +import type { PageLoad } from "./$types"; + +const SearchSchema = z + .object({ + q: z.string().optional(), // song name + artist: z.string().optional(), // artist name + anime: z.string().optional(), // anime mainName + type: z + .string() + .optional() + .transform((s) => { + if (!s) return undefined; + return s + .split(",") + .map((t) => SongTypeMap[t.trim().toUpperCase()]) + .filter((n) => n !== undefined); + }), + gpm: z + .string() + .optional() + .transform((s) => (s ? parseInt(s, 10) : undefined)), // global percent min + gpx: z + .string() + .optional() + .transform((s) => (s ? parseInt(s, 10) : undefined)), // global percent max + cat: z + .string() + .optional() + .transform((s) => (s ? parseInt(s, 10) : undefined)), // category + }) + .strict(); + +export const load: PageLoad = async ({ url, fetch, depends }) => { + depends("clientdb:songs"); + + const parsed = SearchSchema.safeParse( + Object.fromEntries(url.searchParams.entries()), + ); + + const filters: SongFilters = {}; + if (parsed.success) { + if (parsed.data.q) filters.songName = parsed.data.q; + if (parsed.data.artist) filters.artistName = parsed.data.artist; + if (parsed.data.anime) filters.animeName = parsed.data.anime; + if (parsed.data.type && parsed.data.type.length > 0) + filters.songTypes = parsed.data.type; + if (parsed.data.gpm !== undefined) + filters.globalPercentMin = parsed.data.gpm; + if (parsed.data.gpx !== undefined) + filters.globalPercentMax = parsed.data.gpx; + if (parsed.data.cat !== undefined) filters.category = parsed.data.cat; + } + + // Client-only DB: on the server `db` is null, so return [] and let hydration re-run load in browser. + if (!db) { + return { + filters: parsed.success ? parsed.data : {}, // Return original parsed data for form state + songRows: [], + }; + } + + await ensureSeeded({ fetch }); + + const songRows = await getSongsWithFilters(db, filters); + + return { + filters: parsed.success ? parsed.data : {}, // Return original parsed data for form state + songRows, + }; +};