list page pt. 1
This commit is contained in:
340
src/routes/list/+page.svelte
Normal file
340
src/routes/list/+page.svelte
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { desc, eq, inArray } from "drizzle-orm";
|
||||||
|
import { Debounced } from "runed";
|
||||||
|
import { useSearchParams } from "runed/kit";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { ClientDb } from "$lib/db/client-db";
|
||||||
|
import { ensureSeeded, getClientDb } from "$lib/db/client-db";
|
||||||
|
import {
|
||||||
|
anime,
|
||||||
|
animeSongLinks,
|
||||||
|
artists,
|
||||||
|
groups,
|
||||||
|
songs,
|
||||||
|
} from "$lib/db/schema";
|
||||||
|
import {
|
||||||
|
MalAnimeListQuerySchema,
|
||||||
|
MalAnimeListResponseSchema,
|
||||||
|
} from "$lib/types/mal";
|
||||||
|
|
||||||
|
const ListSearchSchema = MalAnimeListQuerySchema.extend({
|
||||||
|
mal: z.string(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
type PageStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
const params = useSearchParams(ListSearchSchema, {
|
||||||
|
pushHistory: false,
|
||||||
|
showDefaults: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let status = $state<PageStatus>("idle");
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
let isLoadingMal = $state(false);
|
||||||
|
let isLoadingDb = $state(false);
|
||||||
|
|
||||||
|
// Keep MAL calls snappy if you later add more query params; for now it's mostly future-proofing.
|
||||||
|
const debouncedMalUser = new Debounced(() => params.mal, 250);
|
||||||
|
|
||||||
|
type MalListResponse = z.infer<typeof MalAnimeListResponseSchema>;
|
||||||
|
type MalEntry = MalListResponse["data"][number];
|
||||||
|
|
||||||
|
let malResponse = $state<MalListResponse | null>(null);
|
||||||
|
let malEntries = $state<MalEntry[]>([]);
|
||||||
|
let malUsername = $state<string | null>(null);
|
||||||
|
|
||||||
|
type SongRow = Awaited<ReturnType<typeof querySongsForMalAnimeIds>>[number];
|
||||||
|
let songRows = $state<SongRow[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch MAL animelist (limited to 20 for now).
|
||||||
|
* Uses zod schema parsing to avoid redefining types.
|
||||||
|
*/
|
||||||
|
async function fetchMalAnimeList(username: string) {
|
||||||
|
const u = username.trim();
|
||||||
|
if (!u) return null;
|
||||||
|
|
||||||
|
// Always limit to 20 for now; we'll add pagination later.
|
||||||
|
const upstream = new URL(
|
||||||
|
`/api/mal/animelist/${encodeURIComponent(u)}`,
|
||||||
|
window.location.origin,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Forward optional query params (status/sort/offset) while forcing limit=20
|
||||||
|
if (params.status) upstream.searchParams.set("status", params.status);
|
||||||
|
if (params.sort) upstream.searchParams.set("sort", params.sort);
|
||||||
|
if (params.offset !== undefined)
|
||||||
|
upstream.searchParams.set("offset", String(params.offset));
|
||||||
|
upstream.searchParams.set("limit", "20");
|
||||||
|
|
||||||
|
const res = await fetch(upstream);
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
res.status === 404
|
||||||
|
? "MAL user not found"
|
||||||
|
: res.status === 401 || res.status === 403
|
||||||
|
? "MAL auth failed"
|
||||||
|
: `MAL request failed (${res.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
return MalAnimeListResponseSchema.parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side DB query:
|
||||||
|
* Given MAL anime ids, find matching `anime` rows by `anime.malId`,
|
||||||
|
* then fetch linked songs with artist/group names.
|
||||||
|
*
|
||||||
|
* Uses inferred types from the Drizzle select shape; no separate type defs.
|
||||||
|
*/
|
||||||
|
async function querySongsForMalAnimeIds(db: ClientDb, malAnimeIds: number[]) {
|
||||||
|
const ids = malAnimeIds.filter((n) => Number.isFinite(n));
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
// 1) Resolve MAL ids -> AMQ annIds
|
||||||
|
const annRows = await db
|
||||||
|
.select({
|
||||||
|
annId: anime.annId,
|
||||||
|
malId: anime.malId,
|
||||||
|
animeName: anime.mainName,
|
||||||
|
year: anime.year,
|
||||||
|
seasonId: anime.seasonId,
|
||||||
|
})
|
||||||
|
.from(anime)
|
||||||
|
.where(inArray(anime.malId, ids));
|
||||||
|
|
||||||
|
const annIds = annRows.map((r) => r.annId);
|
||||||
|
if (annIds.length === 0) return [];
|
||||||
|
|
||||||
|
// 2) Fetch songs linked to those annIds
|
||||||
|
// Note: we keep the selection explicit for stable inferred return types.
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
annId: animeSongLinks.annId,
|
||||||
|
annSongId: animeSongLinks.annSongId,
|
||||||
|
type: animeSongLinks.type,
|
||||||
|
number: animeSongLinks.number,
|
||||||
|
|
||||||
|
songName: songs.name,
|
||||||
|
fileName: songs.fileName,
|
||||||
|
|
||||||
|
artistName: artists.name,
|
||||||
|
groupName: groups.name,
|
||||||
|
|
||||||
|
animeName: anime.mainName,
|
||||||
|
year: anime.year,
|
||||||
|
seasonId: anime.seasonId,
|
||||||
|
malId: anime.malId,
|
||||||
|
})
|
||||||
|
.from(animeSongLinks)
|
||||||
|
.innerJoin(anime, eq(anime.annId, animeSongLinks.annId))
|
||||||
|
.innerJoin(songs, eq(songs.annSongId, animeSongLinks.annSongId))
|
||||||
|
.leftJoin(artists, eq(artists.songArtistId, songs.songArtistId))
|
||||||
|
.leftJoin(groups, eq(groups.songGroupId, songs.songGroupId))
|
||||||
|
.where(inArray(animeSongLinks.annId, annIds))
|
||||||
|
.orderBy(
|
||||||
|
desc(anime.year),
|
||||||
|
desc(anime.seasonId),
|
||||||
|
desc(anime.annId),
|
||||||
|
desc(animeSongLinks.type),
|
||||||
|
desc(animeSongLinks.number),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllFor(username: string) {
|
||||||
|
const u = username.trim();
|
||||||
|
if (!u) {
|
||||||
|
malResponse = null;
|
||||||
|
malEntries = [];
|
||||||
|
malUsername = null;
|
||||||
|
songRows = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
// MAL fetch
|
||||||
|
isLoadingMal = true;
|
||||||
|
try {
|
||||||
|
const r = await fetchMalAnimeList(u);
|
||||||
|
malResponse = r;
|
||||||
|
malEntries = r?.data ?? [];
|
||||||
|
malUsername = u;
|
||||||
|
} finally {
|
||||||
|
isLoadingMal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB query
|
||||||
|
isLoadingDb = true;
|
||||||
|
try {
|
||||||
|
await ensureSeeded();
|
||||||
|
const { db } = getClientDb();
|
||||||
|
|
||||||
|
const malIds = (malResponse?.data ?? []).map((e) => e.node.id);
|
||||||
|
songRows = await querySongsForMalAnimeIds(db, malIds);
|
||||||
|
} finally {
|
||||||
|
isLoadingDb = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInitial() {
|
||||||
|
status = "loading";
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadAllFor(params.mal);
|
||||||
|
status = "ready";
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
status = "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void loadInitial();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (status !== "ready") return;
|
||||||
|
|
||||||
|
const u = debouncedMalUser.current;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await loadAllFor(u);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
status = "error";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
function songArtistLabel(r: SongRow) {
|
||||||
|
return r.artistName ?? r.groupName ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMalHref(username: string) {
|
||||||
|
return `https://myanimelist.net/profile/${encodeURIComponent(username)}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-semibold">MAL List → Songs</h1>
|
||||||
|
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
|
Enter a MAL username in the URL as <code class="rounded bg-muted px-1 py-0.5"
|
||||||
|
>/list?mal=USERNAME</code
|
||||||
|
>. This will fetch up to 20 anime from MAL (for now) and then query your local
|
||||||
|
client DB for related songs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if status === "loading"}
|
||||||
|
<p class="mt-3 text-sm text-muted-foreground">Loading…</p>
|
||||||
|
{:else if status === "error"}
|
||||||
|
<p class="mt-3 text-sm text-red-600">Error: {error}</p>
|
||||||
|
{:else if status === "ready"}
|
||||||
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
|
<label class="text-sm text-muted-foreground" for="mal-user"
|
||||||
|
>MAL username</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="mal-user"
|
||||||
|
class="rounded border px-3 py-2 text-sm"
|
||||||
|
placeholder="e.g. CaZzzer"
|
||||||
|
value={params.mal}
|
||||||
|
oninput={(e) =>
|
||||||
|
(params.mal = (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{#if !params.mal.trim()}
|
||||||
|
Waiting for username…
|
||||||
|
{:else}
|
||||||
|
{#if isLoadingMal}
|
||||||
|
Fetching MAL list…
|
||||||
|
{:else}
|
||||||
|
MAL entries: {malEntries.length} (limited to 20)
|
||||||
|
{/if}
|
||||||
|
{" • "}
|
||||||
|
{#if isLoadingDb}
|
||||||
|
Querying songs…
|
||||||
|
{:else}
|
||||||
|
Songs found: {songRows.length}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if malUsername}
|
||||||
|
<div class="text-sm">
|
||||||
|
<a
|
||||||
|
class="hover:underline"
|
||||||
|
href={makeMalHref(malUsername)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
View {malUsername} on MAL
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if params.mal.trim() && !isLoadingMal && malEntries.length === 0}
|
||||||
|
<p class="mt-4 text-sm text-muted-foreground">
|
||||||
|
No anime returned from MAL (did you set a restrictive status/sort?).
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if params.mal.trim() && !isLoadingDb && malEntries.length > 0 && songRows.length === 0}
|
||||||
|
<p class="mt-4 text-sm text-muted-foreground">
|
||||||
|
No songs matched in the local database. This likely means none of the MAL
|
||||||
|
anime IDs exist in the snapshot DB.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if songRows.length > 0}
|
||||||
|
<h2 class="mt-6 text-lg font-semibold">Songs</h2>
|
||||||
|
|
||||||
|
<ul class="mt-3 space-y-2">
|
||||||
|
{#each songRows as r (String(r.annId) + ":" + String(r.annSongId))}
|
||||||
|
<li class="rounded border px-3 py-2">
|
||||||
|
<div class="font-medium">
|
||||||
|
{r.songName}
|
||||||
|
{#if songArtistLabel(r)}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
— {songArtistLabel(r)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{r.animeName} • ANN {r.annId} • MAL {r.malId} • link type {r.type} #{r.number}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if r.fileName}
|
||||||
|
<div class="mt-2 text-sm">
|
||||||
|
<a
|
||||||
|
class="hover:underline"
|
||||||
|
href={"https://nawdist.animemusicquiz.com/" + r.fileName}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Audio file
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if malResponse?.paging?.next}
|
||||||
|
<p class="mt-6 text-sm text-muted-foreground">
|
||||||
|
More results exist on MAL, but pagination is not wired yet.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
Reference in New Issue
Block a user