Files
amqtrain/src/lib/db/client-db/queries.ts

319 lines
8.1 KiB
TypeScript

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)));
}