319 lines
8.1 KiB
TypeScript
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)));
|
|
}
|