success pt. 8.0
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
import { desc, like } from "drizzle-orm";
|
import { desc, eq, like } from "drizzle-orm";
|
||||||
import { animeTable } from "$lib/db/schema";
|
import {
|
||||||
|
animeSongLinksTable,
|
||||||
|
animeTable,
|
||||||
|
artistsTable,
|
||||||
|
groupsTable,
|
||||||
|
songsTable,
|
||||||
|
} from "$lib/db/schema";
|
||||||
import type { ClientDb } from "./index";
|
import type { ClientDb } from "./index";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +81,69 @@ export async function searchAnimeByName(
|
|||||||
.limit(safeLimit);
|
.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: animeTable.annId,
|
||||||
|
mainName: animeTable.mainName,
|
||||||
|
year: animeTable.year,
|
||||||
|
seasonId: animeTable.seasonId,
|
||||||
|
malId: animeTable.malId,
|
||||||
|
})
|
||||||
|
.from(animeTable)
|
||||||
|
.where(eq(animeTable.annId, annId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const anime = animeRows[0];
|
||||||
|
if (!anime) return null;
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
annSongId: animeSongLinksTable.annSongId,
|
||||||
|
type: animeSongLinksTable.type,
|
||||||
|
number: animeSongLinksTable.number,
|
||||||
|
|
||||||
|
songName: songsTable.name,
|
||||||
|
fileName: songsTable.fileName,
|
||||||
|
|
||||||
|
artistName: artistsTable.name,
|
||||||
|
groupName: groupsTable.name,
|
||||||
|
})
|
||||||
|
.from(animeSongLinksTable)
|
||||||
|
.innerJoin(
|
||||||
|
songsTable,
|
||||||
|
eq(songsTable.annSongId, animeSongLinksTable.annSongId),
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
artistsTable,
|
||||||
|
eq(artistsTable.songArtistId, songsTable.songArtistId),
|
||||||
|
)
|
||||||
|
.leftJoin(groupsTable, eq(groupsTable.songGroupId, songsTable.songGroupId))
|
||||||
|
.where(eq(animeSongLinksTable.annId, annId))
|
||||||
|
.orderBy(desc(animeSongLinksTable.type), desc(animeSongLinksTable.number));
|
||||||
|
|
||||||
|
return {
|
||||||
|
anime,
|
||||||
|
songs: rows.map((r) => ({
|
||||||
|
annSongId: r.annSongId,
|
||||||
|
type: r.type,
|
||||||
|
number: r.number,
|
||||||
|
songName: r.songName,
|
||||||
|
fileName: r.fileName,
|
||||||
|
artistName: r.artistName ?? r.groupName ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function clampLimit(limit: number) {
|
function clampLimit(limit: number) {
|
||||||
const n = Number(limit);
|
const n = Number(limit);
|
||||||
if (!Number.isFinite(n)) return DEFAULT_LIST_LIMIT;
|
if (!Number.isFinite(n)) return DEFAULT_LIST_LIMIT;
|
||||||
|
|||||||
@@ -331,6 +331,13 @@ export async function importAmqData(
|
|||||||
songId: s.songId,
|
songId: s.songId,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
category: s.category,
|
category: s.category,
|
||||||
|
fileName: s.fileName,
|
||||||
|
songArtistId: s.songArtistId,
|
||||||
|
songGroupId: s.songGroupId,
|
||||||
|
composerArtistId: s.composerArtistId,
|
||||||
|
composerGroupId: s.composerGroupId,
|
||||||
|
arrangerArtistId: s.arrangerArtistId,
|
||||||
|
arrangerGroupId: s.arrangerGroupId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
for (const batch of chunk(songRows, batchSize)) {
|
for (const batch of chunk(songRows, batchSize)) {
|
||||||
@@ -343,6 +350,13 @@ export async function importAmqData(
|
|||||||
songId: songsTable.songId,
|
songId: songsTable.songId,
|
||||||
name: songsTable.name,
|
name: songsTable.name,
|
||||||
category: songsTable.category,
|
category: songsTable.category,
|
||||||
|
fileName: songsTable.fileName,
|
||||||
|
songArtistId: songsTable.songArtistId,
|
||||||
|
songGroupId: songsTable.songGroupId,
|
||||||
|
composerArtistId: songsTable.composerArtistId,
|
||||||
|
composerGroupId: songsTable.composerGroupId,
|
||||||
|
arrangerArtistId: songsTable.arrangerArtistId,
|
||||||
|
arrangerGroupId: songsTable.arrangerGroupId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|||||||
@@ -81,9 +81,51 @@ export const songsTable = sqliteTable(
|
|||||||
* none(0), instrumental(1), chanting(2), character(3), standard(4)
|
* none(0), instrumental(1), chanting(2), character(3), standard(4)
|
||||||
*/
|
*/
|
||||||
category: integer("category").notNull(),
|
category: integer("category").notNull(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Song audio filename used by the AMQ CDN:
|
||||||
|
* https://nawdist.animemusicquiz.com/{fileName}
|
||||||
|
*/
|
||||||
|
fileName: text("file_name"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary artist/group ids for this song (nullable in source).
|
||||||
|
* These reference existing `artists` / `groups` rows.
|
||||||
|
*/
|
||||||
|
songArtistId: integer("song_artist_id").references(
|
||||||
|
() => artistsTable.songArtistId,
|
||||||
|
{ onDelete: "set null" },
|
||||||
|
),
|
||||||
|
songGroupId: integer("song_group_id").references(
|
||||||
|
() => groupsTable.songGroupId,
|
||||||
|
{ onDelete: "set null" },
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional contributor ids (nullable in source)
|
||||||
|
*/
|
||||||
|
composerArtistId: integer("composer_artist_id").references(
|
||||||
|
() => artistsTable.songArtistId,
|
||||||
|
{ onDelete: "set null" },
|
||||||
|
),
|
||||||
|
composerGroupId: integer("composer_group_id").references(
|
||||||
|
() => groupsTable.songGroupId,
|
||||||
|
{ onDelete: "set null" },
|
||||||
|
),
|
||||||
|
arrangerArtistId: integer("arranger_artist_id").references(
|
||||||
|
() => artistsTable.songArtistId,
|
||||||
|
{ onDelete: "set null" },
|
||||||
|
),
|
||||||
|
arrangerGroupId: integer("arranger_group_id").references(
|
||||||
|
() => groupsTable.songGroupId,
|
||||||
|
{ onDelete: "set null" },
|
||||||
|
),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
songIdIndex: index("songs_song_id_idx").on(t.songId),
|
songIdIndex: index("songs_song_id_idx").on(t.songId),
|
||||||
|
fileNameIndex: index("songs_file_name_idx").on(t.fileName),
|
||||||
|
songArtistIdIndex: index("songs_song_artist_id_idx").on(t.songArtistId),
|
||||||
|
songGroupIdIndex: index("songs_song_group_id_idx").on(t.songGroupId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function animeHref(annId: number) {
|
||||||
|
return `/anime/${annId}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadInitial() {
|
async function loadInitial() {
|
||||||
status = "loading";
|
status = "loading";
|
||||||
error = null;
|
error = null;
|
||||||
@@ -112,7 +116,9 @@
|
|||||||
<ul class="mt-4 space-y-2">
|
<ul class="mt-4 space-y-2">
|
||||||
{#each anime as a (a.annId)}
|
{#each anime as a (a.annId)}
|
||||||
<li class="rounded border px-3 py-2">
|
<li class="rounded border px-3 py-2">
|
||||||
<div class="font-medium">{a.mainName}</div>
|
<a class="font-medium hover:underline" href={animeHref(a.annId)}>
|
||||||
|
{a.mainName}
|
||||||
|
</a>
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="text-sm text-muted-foreground">
|
||||||
{a.year}
|
{a.year}
|
||||||
{seasonName(a.seasonId)} • ANN {a.annId} • MAL {a.malId}
|
{seasonName(a.seasonId)} • ANN {a.annId} • MAL {a.malId}
|
||||||
|
|||||||
148
src/routes/anime/[annId]/+page.svelte
Normal file
148
src/routes/anime/[annId]/+page.svelte
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { db, ensureSeeded } from "$lib/db/client-db";
|
||||||
|
import { getAnimeWithSongsByAnnId } from "$lib/db/client-db/queries";
|
||||||
|
import { seasonName } from "$lib/utils/amq";
|
||||||
|
|
||||||
|
type PageStatus = "idle" | "loading" | "ready" | "not-found" | "error";
|
||||||
|
|
||||||
|
let status = $state<PageStatus>("idle");
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
type Data = Awaited<ReturnType<typeof getAnimeWithSongsByAnnId>>;
|
||||||
|
let data = $state<NonNullable<Data> | null>(null);
|
||||||
|
|
||||||
|
function parseAnnId(value: string | null): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) return null;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function songTypeLabel(type: number) {
|
||||||
|
// Matches schema comment in `anime_song_links.type`: 1(OP) | 2(ED) | 3(INS)
|
||||||
|
switch (type) {
|
||||||
|
case 1:
|
||||||
|
return "OP";
|
||||||
|
case 2:
|
||||||
|
return "ED";
|
||||||
|
case 3:
|
||||||
|
return "INS";
|
||||||
|
default:
|
||||||
|
return "SONG";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function songCode(type: number, number: number) {
|
||||||
|
return `${songTypeLabel(type)}${number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioUrl(fileName: string) {
|
||||||
|
return `https://nawdist.animemusicquiz.com/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
status = "loading";
|
||||||
|
error = null;
|
||||||
|
data = null;
|
||||||
|
|
||||||
|
const annId = parseAnnId(page.params.annId ?? null);
|
||||||
|
if (!annId) {
|
||||||
|
status = "not-found";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSeeded();
|
||||||
|
const res = await getAnimeWithSongsByAnnId(db, annId);
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
status = "not-found";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data = res;
|
||||||
|
status = "ready";
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
status = "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if status === "loading"}
|
||||||
|
<p class="mt-3 text-sm text-muted-foreground">Loading anime…</p>
|
||||||
|
{:else if status === "error"}
|
||||||
|
<p class="mt-3 text-sm text-red-600">Error: {error}</p>
|
||||||
|
{:else if status === "not-found"}
|
||||||
|
<h1 class="text-2xl font-semibold">Anime not found</h1>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
|
The requested anime entry doesn’t exist (or the route param wasn’t a valid
|
||||||
|
ANN id).
|
||||||
|
</p>
|
||||||
|
{:else if status === "ready" && data}
|
||||||
|
<header class="mt-2 space-y-1">
|
||||||
|
<h1 class="text-2xl font-semibold">{data.anime.mainName}</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{data.anime.year}{seasonName(data.anime.seasonId)} • ANN {data.anime
|
||||||
|
.annId} • MAL {data.anime.malId}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="mt-6">
|
||||||
|
<h2 class="text-lg font-semibold">Songs</h2>
|
||||||
|
|
||||||
|
{#if data.songs.length === 0}
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
|
No linked songs found for this anime.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="mt-3 space-y-3">
|
||||||
|
{#each data.songs as s (s.annSongId)}
|
||||||
|
<li class="rounded border p-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1"
|
||||||
|
>
|
||||||
|
<div class="font-medium">
|
||||||
|
<span
|
||||||
|
class="mr-2 inline-flex rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{songCode(s.type, s.number)}
|
||||||
|
</span>
|
||||||
|
{s.songName}
|
||||||
|
</div>
|
||||||
|
{#if s.artistName}
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{s.artistName}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if s.fileName}
|
||||||
|
<audio
|
||||||
|
class="mt-2 w-full"
|
||||||
|
controls
|
||||||
|
preload="none"
|
||||||
|
src={audioUrl(s.fileName)}
|
||||||
|
></audio>
|
||||||
|
<div class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Source: <span class="font-mono">{s.fileName}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
|
No audio filename in the snapshot for this song.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
Reference in New Issue
Block a user