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"; /** * Client-side DB query helpers (read-only). * * These functions are intentionally side-effect free — seeding/overwriting the DB * file should be done separately (see `seed.ts`). * * We accept the concrete SQLocal-backed Drizzle DB type so callers get full type inference. */ export const DEFAULT_LIST_LIMIT = 20; export const MAX_LIST_LIMIT = 200; /** * Get a short list of anime entries for browsing. * * Sorted by (year desc, seasonId desc, annId desc). */ export async function getAnimeList(db: ClientDb, limit = DEFAULT_LIST_LIMIT) { const safeLimit = clampLimit(limit); // Explicit selection keeps payload small and gives a stable, inferred return type return db .select({ annId: anime.annId, mainName: anime.mainName, year: anime.year, seasonId: anime.seasonId, malId: anime.malId, aniListId: anime.aniListId, opCount: anime.opCount, edCount: anime.edCount, insertCount: anime.insertCount, }) .from(anime) .orderBy(desc(anime.year), desc(anime.seasonId), desc(anime.annId)) .limit(safeLimit); } /** * Simple `LIKE` search over `anime.main_name`. * * If you want fast/quality search later, consider shipping an FTS5 virtual table * in the snapshot DB, and querying that instead. */ export async function searchAnimeByName( db: ClientDb, query: string, limit = DEFAULT_LIST_LIMIT, ) { const q = query.trim(); if (!q) return []; const safeLimit = clampLimit(limit); const pattern = `%${q}%`; return db .select({ annId: anime.annId, mainName: anime.mainName, year: anime.year, seasonId: anime.seasonId, malId: anime.malId, aniListId: anime.aniListId, opCount: anime.opCount, edCount: anime.edCount, insertCount: anime.insertCount, }) .from(anime) .where(like(anime.mainName, pattern)) .orderBy(desc(anime.year), desc(anime.seasonId), desc(anime.annId)) .limit(safeLimit); } /** * Fetch a single anime plus its linked songs, including: * - anime name * - link metadata: type + number (OP1/ED2/etc) * - song metadata: song name, artist/group name, fileName for audio playback * * Note: this assumes the SQLite snapshot has `songs.file_name`, `songs.song_artist_id`, * and `songs.song_group_id` populated. */ export async function getAnimeWithSongsByAnnId(db: ClientDb, annId: number) { const animeRows = await db .select({ annId: anime.annId, mainName: anime.mainName, year: anime.year, seasonId: anime.seasonId, malId: anime.malId, aniListId: anime.aniListId, }) .from(anime) .where(eq(anime.annId, annId)) .limit(1); const foundAnime = animeRows[0]; if (!foundAnime) return null; const rows = await db .select({ annSongId: animeSongLinks.annSongId, type: animeSongLinks.type, number: animeSongLinks.number, dub: animeSongLinks.dub, rebroadcast: animeSongLinks.rebroadcast, songName: songs.name, fileName: songs.fileName, globalPercent: songs.globalPercent, artistName: artists.name, groupName: groups.name, }) .from(animeSongLinks) .innerJoin(songs, eq(songs.annSongId, animeSongLinks.annSongId)) .leftJoin(artists, eq(artists.songArtistId, songs.songArtistId)) .leftJoin(groups, eq(groups.songGroupId, songs.songGroupId)) .where(eq(animeSongLinks.annId, annId)) .orderBy(desc(animeSongLinks.type), desc(animeSongLinks.number)); return { anime: foundAnime, songs: rows.map((r) => ({ annSongId: r.annSongId, type: r.type, number: r.number, dub: r.dub, rebroadcast: r.rebroadcast, songName: r.songName, fileName: r.fileName, globalPercent: r.globalPercent, artistName: r.artistName ?? r.groupName ?? null, })), }; } /** * Fetch songs for a list of MAL anime ids. * * This joins `anime` -> `anime_song_links` -> `songs` and includes artist/group name. * * Intended usage: MAL animelist entries give you `node.id` (MAL id); pass those ids here. */ export async function getSongsForMalAnimeIds( db: ClientDb, malAnimeIds: number[], ) { const ids = malAnimeIds.filter((n) => Number.isFinite(n)); if (ids.length === 0) return []; return db .select({ annId: anime.annId, malId: anime.malId, aniListId: anime.aniListId, animeName: anime.mainName, year: anime.year, seasonId: anime.seasonId, annSongId: animeSongLinks.annSongId, type: animeSongLinks.type, number: animeSongLinks.number, dub: animeSongLinks.dub, rebroadcast: animeSongLinks.rebroadcast, songName: songs.name, fileName: songs.fileName, globalPercent: songs.globalPercent, artistName: artists.name, groupName: groups.name, }) .from(anime) .innerJoin(animeSongLinks, eq(animeSongLinks.annId, anime.annId)) .innerJoin(songs, eq(songs.annSongId, animeSongLinks.annSongId)) .leftJoin(artists, eq(artists.songArtistId, songs.songArtistId)) .leftJoin(groups, eq(groups.songGroupId, songs.songGroupId)) .where(inArray(anime.malId, ids)) .orderBy( desc(anime.year), desc(anime.seasonId), desc(anime.annId), desc(animeSongLinks.type), desc(animeSongLinks.number), ); } // 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, dub: animeSongLinks.dub, rebroadcast: animeSongLinks.rebroadcast, 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; return Math.max(1, Math.min(MAX_LIST_LIMIT, Math.floor(n))); }