9 Commits

Author SHA1 Message Date
b37eef8f31 prototype anime list input 2026-02-13 16:56:12 -08:00
a144baba2b list: prototype without nested params 2026-02-13 01:04:49 -08:00
21d62f8c6f list: prototype with combined list field 2026-02-13 01:03:50 -08:00
f90cf66cc1 rename list page to mal 2026-02-12 23:44:02 -08:00
1a3ec7d84e ui: reduce gap in layout 2026-02-12 23:25:52 -08:00
7dc37d9eb7 ui: player: fix volume slider overflow in desktop player controls 2026-02-12 22:36:47 -08:00
ec3565078f feat(queue): auto-scroll to currently playing on queue open
Adds a visible prop to Queue that triggers auto-scroll to the currently
playing track when the queue becomes visible. PlayerMobile passes the
drawer open state, PlayerDesktop passes whether a track exists.
2026-02-12 22:19:12 -08:00
e3c0c6cade feat(queue): add scroll to currently playing button
Adds a scrollToIndex method to VirtualList and a locate button in the
Queue header that scrolls to center the currently playing track.
2026-02-12 22:19:12 -08:00
28643c38b8 feat(queue): add confirmation dialog when clearing the queue
Wraps the clear button in an AlertDialog that shows the number of songs
to be removed, requiring explicit confirmation before clearing.
2026-02-12 21:56:14 -08:00
15 changed files with 539 additions and 335 deletions

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { AnimeListCodec } from "./schema";
import { ChipGroup } from "$lib/components/ui/chip-group";
import { AnimeListWatchStatus } from "$lib/utils/list";
import NativeSelect from "$lib/components/ui/native-select/native-select.svelte";
import NativeSelectOption from "$lib/components/ui/native-select/native-select-option.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import { Label } from "$lib/components/ui/label";
import { z } from "zod";
let { value = $bindable() }: { value: z.infer<typeof AnimeListCodec> } =
$props();
</script>
<div class="flex flex-col gap-2">
<Label for="list-kind">Kind</Label>
<NativeSelect id="list-kind" bind:value={value.kind}>
<NativeSelectOption value="mal">MAL</NativeSelectOption>
<NativeSelectOption value="anilist">AniList</NativeSelectOption>
<NativeSelectOption value="kitsu">Kitsu</NativeSelectOption>
</NativeSelect>
</div>
<div class="flex flex-col gap-2">
<Label for="list-username">Username</Label>
<Input id="list-username" bind:value={value.username} />
</div>
<div class="flex flex-col gap-2">
<Label for="list-status">Status</Label>
<ChipGroup
items={AnimeListWatchStatus.options.map((v) => ({
label: v.toUpperCase(),
value: v,
}))}
bind:value={value.status}
/>
</div>

View File

@@ -0,0 +1,2 @@
export { default as AnimeListInput } from "./AnimeListInput.svelte";
export * from "./schema";

View File

@@ -0,0 +1,16 @@
import { AnimeList, AnimeListWatchStatus } from "$lib/utils/list";
import { z } from "zod";
const SEP_FIELD = ":";
const SEP_VALUE = ",";
export const AnimeListCodec = z.codec(z.string(), AnimeList, {
decode: (s) => {
const [kind, ...rest] = decodeURIComponent(s).split(SEP_FIELD);
const statusStr = rest.pop();
const status = statusStr ? statusStr.split(SEP_VALUE).map((v) => AnimeListWatchStatus.parse(v)) : [];
const username = rest.join("");
return AnimeList.parse({ kind, username, status });
},
encode: (list) => encodeURIComponent(`${list.kind}${SEP_FIELD}${list.username}${SEP_FIELD}${list.status.join(SEP_VALUE)}`),
});

View File

@@ -24,7 +24,7 @@
class="h-full flex flex-col border-l bg-background/50 backdrop-blur w-full" class="h-full flex flex-col border-l bg-background/50 backdrop-blur w-full"
> >
{#if player.currentTrack} {#if player.currentTrack}
<div class="p-6 space-y-4 shrink-0"> <div class="p-4 space-y-4 shrink-0">
<!-- Track Info --> <!-- Track Info -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<h2 class="text-lg font-bold leading-tight"> <h2 class="text-lg font-bold leading-tight">
@@ -63,7 +63,7 @@
<div class="flex justify-center gap-4 divide-x divide-accent"> <div class="flex justify-center gap-4 divide-x divide-accent">
<Controls /> <Controls />
<!-- Volume --> <!-- Volume -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 min-w-0">
<button <button
onclick={() => player.toggleMute()} onclick={() => player.toggleMute()}
class="text-muted-foreground hover:text-foreground transition-colors" class="text-muted-foreground hover:text-foreground transition-colors"
@@ -82,7 +82,7 @@
bind:value={player.volume} bind:value={player.volume}
max={1} max={1}
step={0.05} step={0.05}
class="flex-1" class="flex-1 min-w-0"
/> />
</div> </div>
</div> </div>
@@ -95,7 +95,7 @@
<div class="flex-1 overflow-hidden relative p-4"> <div class="flex-1 overflow-hidden relative p-4">
<div class="absolute inset-0 p-4 pt-0"> <div class="absolute inset-0 p-4 pt-0">
<div class="h-full overflow-hidden rounded-lg border bg-muted/20"> <div class="h-full overflow-hidden rounded-lg border bg-muted/20">
<Queue /> <Queue visible={!!player.currentTrack} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -108,7 +108,7 @@
<!-- Queue --> <!-- Queue -->
<div class="flex-1 overflow-hidden relative mt-auto"> <div class="flex-1 overflow-hidden relative mt-auto">
<div class="absolute inset-0"> <div class="absolute inset-0">
<Queue /> <Queue visible={open} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,11 +1,31 @@
<script lang="ts"> <script lang="ts">
import { GripVertical, Play, X } from "@lucide/svelte"; import { GripVertical, LocateFixed, Play, X } from "@lucide/svelte";
import { tick } from "svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import VirtualList from "$lib/components/ui/VirtualList.svelte"; import VirtualList from "$lib/components/ui/VirtualList.svelte";
import { player } from "$lib/player/store.svelte"; import { player } from "$lib/player/store.svelte";
import type { Track } from "$lib/player/types"; import type { Track } from "$lib/player/types";
import { songTypeNumberLabel } from "$lib/utils/amq"; import { songTypeNumberLabel } from "$lib/utils/amq";
let { visible = true }: { visible?: boolean } = $props();
let virtualList: ReturnType<typeof VirtualList>;
function scrollToCurrentlyPlaying() {
if (player.currentId == null) return;
const index = player.displayQueue.findIndex(
(t) => t.id === player.currentId,
);
if (index !== -1) virtualList?.scrollToIndex(index);
}
$effect(() => {
if (visible) {
tick().then(() => scrollToCurrentlyPlaying());
}
});
const ITEM_HEIGHT = 64; const ITEM_HEIGHT = 64;
function onRemove(id: number) { function onRemove(id: number) {
@@ -44,27 +64,53 @@
<div <div
class="flex flex-col h-full w-full bg-background/50 backdrop-blur rounded-lg border overflow-hidden" class="flex flex-col h-full w-full bg-background/50 backdrop-blur rounded-lg border overflow-hidden"
> >
<div class="px-4 py-3 border-b flex justify-between items-center bg-muted/20"> <div
<h3 class="font-semibold text-sm"> class="px-4 py-3 border-b flex text-sm items-center justify-between bg-muted/20"
Up Next >
<div class="flex items-center gap-1">
<h3 class="font-semibold">Up Next</h3>
{#if player.displayQueue.length > 0} {#if player.displayQueue.length > 0}
<span class="text-muted-foreground font-normal ml-1" <span class="text-muted-foreground font-normal ml-1"
>({player.displayQueue.length})</span >({player.displayQueue.length})</span
> >
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0"
aria-label="Scroll to currently playing"
onclick={scrollToCurrentlyPlaying}
>
<LocateFixed class="h-3 w-3" />
</Button>
{/if} {/if}
</h3> </div>
<Button <AlertDialog.Root>
variant="ghost" <AlertDialog.Trigger>
size="sm" {#snippet child({ props })}
class="h-6 w-6 p-0" <Button variant="ghost" size="sm" class="h-6 w-6 p-0" {...props}>
onclick={() => player.clearQueue()} <X class="h-3 w-3" aria-label="Clear" />
> </Button>
<span class="sr-only">Clear</span> {/snippet}
<X class="h-3 w-3" /> </AlertDialog.Trigger>
</Button> <AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Clear queue?</AlertDialog.Title>
<AlertDialog.Description>
This will remove all {player.displayQueue.length} songs from the queue.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={() => player.clearQueue()}
>Clear</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div> </div>
<VirtualList <VirtualList
bind:this={virtualList}
items={player.displayQueue} items={player.displayQueue}
itemHeight={ITEM_HEIGHT} itemHeight={ITEM_HEIGHT}
overscan={5} overscan={5}

View File

@@ -56,6 +56,14 @@
scrollTop = (e.target as HTMLDivElement).scrollTop; scrollTop = (e.target as HTMLDivElement).scrollTop;
} }
export function scrollToIndex(index: number) {
if (!containerEl) return;
containerEl.scrollTop = Math.max(
0,
index * itemHeight - containerHeight / 2 + itemHeight / 2,
);
}
$effect(() => { $effect(() => {
if (!containerEl) return; if (!containerEl) return;
const ro = new ResizeObserver((entries) => { const ro = new ResizeObserver((entries) => {

View File

@@ -0,0 +1,36 @@
import z from "zod";
export const MAL_URL = "https://myanimelist.net";
export const ANILIST_URL = "https://anilist.co";
export const KITSU_URL = "https://kitsu.io";
export const AnimeListKind = z.enum([
"mal",
"anilist",
"kitsu",
])
export const AnimeListWatchStatus = z.enum({
"completed": "c",
"watching": "w",
"plan_to_watch": "p",
"on_hold": "h",
"dropped": "d",
} as const)
export const AnimeList = z.object({
kind: AnimeListKind,
username: z.string(),
status: z.array(AnimeListWatchStatus),
});
export function listExternalUrl(list: z.infer<typeof AnimeList>) {
switch (list.kind) {
case "mal":
return `${MAL_URL}/profile/${encodeURIComponent(list.username)}`;
case "anilist":
return `${ANILIST_URL}/user/${encodeURIComponent(list.username)}`;
case "kitsu":
return `${KITSU_URL}/username/${encodeURIComponent(list.username)}`;
}
}

View File

@@ -18,7 +18,7 @@
- Desktop: 2-column grid, right column reserved for the in-flow player sidebar - Desktop: 2-column grid, right column reserved for the in-flow player sidebar
--> -->
<div <div
class="flex flex-col min-h-dvh not-lg:w-full not-xl:min-w-[80dvw] lg:grid lg:grid-rows-[auto_1fr] gap-16 lg:grid-cols-[1fr_420px]" class="flex flex-col min-h-dvh not-lg:w-full not-xl:min-w-[80dvw] lg:grid lg:grid-rows-[auto_1fr] gap-4 lg:grid-cols-[1fr_420px]"
> >
<header <header
class="sticky top-0 z-40 border-b bg-background/80 backdrop-blur lg:col-span-2" class="sticky top-0 z-40 border-b bg-background/80 backdrop-blur lg:col-span-2"
@@ -30,6 +30,7 @@
<a href={resolve("/")}>Anime</a> <a href={resolve("/")}>Anime</a>
<a href={resolve("/songs")}>Songs</a> <a href={resolve("/songs")}>Songs</a>
<a href={resolve("/list")}>List</a> <a href={resolve("/list")}>List</a>
<a href={resolve("/mal")}>MAL</a>
</nav> </nav>
</div> </div>
</header> </header>

View File

@@ -1,226 +1,44 @@
<script lang="ts"> <script lang="ts">
import { useSearchParams } from "runed/kit"; import { useSearchParams } from "runed/kit";
import { onMount } from "svelte"; import type { PageData } from "./$types";
import { z } from "zod"; import { SearchParamsSchema } from "./schema";
import { browser } from "$app/environment"; import { Button } from "$lib/components/ui/button";
import { invalidate } from "$app/navigation"; import { z } from "zod";
import SongEntry from "$lib/components/SongEntry.svelte"; import {
import { db as clientDb } from "$lib/db/client-db"; AnimeListCodec,
import { player } from "$lib/player/store.svelte"; AnimeListInput,
import { trackFromSongRow } from "$lib/player/types"; } from "$lib/components/inputs/anime-list-input";
import {
MalAnimeListQuerySchema,
MalAnimeListStatusEnum,
} from "$lib/types/mal";
import type { PageData } from "./$types";
const ListSearchSchema = MalAnimeListQuerySchema.extend({ let { data }: { data: PageData } = $props();
// Allow empty string to mean "All"
status: MalAnimeListStatusEnum.or(z.literal("")).default(""),
// URL param `mal` is updated only on Search
mal: z.string().default(""),
}).strict();
const params = useSearchParams(ListSearchSchema, { const params = useSearchParams(SearchParamsSchema, {
pushHistory: false, pushHistory: false,
showDefaults: false, });
});
let { data }: { data: PageData } = $props(); let formState: z.infer<typeof AnimeListCodec> = $state({
kind: "mal",
username: "",
status: [],
});
// Local username field that does NOT update the URL as you type. // $inspect(formState);
let formMal = $state<string>(params.mal);
// If SSR returned no songRows (because client DB wasn't available), $effect(() => {
// re-run load on the client once the DB is ready by invalidating. console.log("formState", formState);
onMount(() => { });
if (data.songRows.length > 0) return;
if (!data.username || !data.malResponse) return;
if (clientDb) {
void invalidate("clientdb:songs");
return;
}
});
function songArtistLabel(r: (typeof data.songRows)[number]) {
return r.artistName ?? r.groupName ?? null;
}
function makeMalHref(username: string) {
return `https://myanimelist.net/profile/${encodeURIComponent(username)}`;
}
const tracksFromResults = $derived.by(() =>
data.songRows
.map((r) =>
trackFromSongRow({
annSongId: r.annSongId,
animeName: r.animeName,
type: r.type,
number: r.number,
songName: r.songName,
artistName: songArtistLabel(r),
fileName: r.fileName,
dub: Boolean(r.dub),
rebroadcast: Boolean(r.rebroadcast),
globalPercent: r.globalPercent,
}),
)
.filter((t) => t !== null),
);
</script> </script>
<h1 class="text-2xl font-semibold">MAL List → Songs</h1> <h1 class="text-2xl font-semibold">List Search WIP</h1>
<p class="mt-2 text-sm text-muted-foreground">
{#if !clientDb}
Loading DB...
{/if}
</p>
<form <form
class="mt-4 flex flex-col gap-2" onsubmit={(e) => {
onsubmit={(e) => { e.preventDefault();
e.preventDefault(); params.kind = formState.kind;
params.mal = formMal; params.username = formState.username;
}} params.status = formState.status;
}}
class="flex flex-wrap items-end gap-2"
> >
<div class="flex flex-wrap gap-2"> <AnimeListInput bind:value={formState} />
<div class="flex flex-col gap-2"> <Button type="submit">Search</Button>
<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"
bind:value={formMal}
autocomplete="off"
spellcheck={false}
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground" for="mal-status"
>Status</label
>
<select
id="mal-status"
class="rounded border px-3 py-2 text-sm"
bind:value={params.status}
>
<option value="">All</option>
<option value="watching">Watching</option>
<option value="completed">Completed</option>
<option value="on_hold">On hold</option>
<option value="dropped">Dropped</option>
<option value="plan_to_watch">Plan to watch</option>
</select>
</div>
<div class="flex flex-col justify-end">
<button
type="submit"
class="rounded border px-3 py-2 text-sm"
disabled={!(formMal ?? "").trim()}
>
Search
</button>
</div>
</div>
<div class="text-sm text-muted-foreground">
{#if data.username}
MAL entries: {data.malResponse?.data.length ?? 0} (limited to {data.LIST_QUERY_LIMIT})
• Songs found: {data.songRows.length}
{/if}
</div>
{#if data.songRows.length > 0}
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded border px-3 py-2 text-sm"
onclick={() => player.addAll(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Add all to queue
</button>
<button
type="button"
class="rounded border px-3 py-2 text-sm"
onclick={() => player.playAllNext(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Play all next
</button>
{#if tracksFromResults.length !== data.songRows.length}
<span class="self-center text-sm text-muted-foreground">
({tracksFromResults.length} playable)
</span>
{/if}
</div>
{/if}
{#if data.username}
<div class="text-sm">
<a
class="hover:underline"
href={makeMalHref(data.username)}
target="_blank"
rel="noreferrer"
>
View {data.username} on MAL
</a>
</div>
{/if}
</form> </form>
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) === 0}
<p class="mt-4 text-sm text-muted-foreground">
No anime returned from MAL (did you set a restrictive status?).
</p>
{/if}
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) > 0 && data.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 AMQ DB.
</p>
{/if}
{#if data.songRows.length > 0}
<h2 class="mt-6 text-lg font-semibold">Songs</h2>
<ul class="mt-3 space-y-2">
{#each data.songRows as r (String(r.annId) + ":" + String(r.annSongId))}
<li>
<SongEntry
annSongId={r.annSongId}
animeName={r.animeName}
type={r.type}
number={r.number}
songName={r.songName}
artistName={songArtistLabel(r)}
fileName={r.fileName}
globalPercent={r.globalPercent}
dub={Boolean(r.dub)}
rebroadcast={Boolean(r.rebroadcast)}
/>
</li>
{/each}
</ul>
{/if}
{#if browser && data.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 !browser}
Loading stuff...
{/if}

View File

@@ -1,100 +1,7 @@
import { z } from "zod";
// Import client-db index directly as requested.
// On the server, `db` will be null (because `browser` is false in that module).
import { db, ensureSeeded, getSongsForMalAnimeIds } from "$lib/db/client-db";
import {
MalAnimeListQuerySchema,
MalAnimeListResponseSchema,
MalAnimeListStatusEnum,
} from "$lib/types/mal";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
import { SearchParamsSchema } from "./schema";
const LIST_QUERY_LIMIT = 1000;
const SearchSchema = MalAnimeListQuerySchema.extend({
// Username
mal: z.string().optional(),
// Allow empty string to mean "All"
status: MalAnimeListStatusEnum.or(z.literal("")).optional(),
}).strict();
type StatusParam = z.infer<typeof SearchSchema>["status"];
function normalizeStatus(
status: StatusParam,
): z.infer<typeof MalAnimeListStatusEnum> | undefined {
if (status == null || status === "") return undefined;
return status;
}
export const load: PageLoad = async ({ url, fetch, depends }) => { export const load: PageLoad = async ({ url, fetch, depends }) => {
depends("mal:animelist"); const parsed = SearchParamsSchema.safeParse(url.searchParams);
depends("clientdb:songs"); console.log(parsed);
}
const parsed = SearchSchema.safeParse(
Object.fromEntries(url.searchParams.entries()),
);
const mal = parsed.success ? parsed.data.mal : undefined;
const status = parsed.success
? normalizeStatus(parsed.data.status)
: undefined;
const username = (mal ?? "").trim();
// Always return a stable shape for hydration
if (!username) {
return {
LIST_QUERY_LIMIT,
username: "",
status: status ?? null,
malResponse: null as z.infer<typeof MalAnimeListResponseSchema> | null,
songRows: [] as Awaited<ReturnType<typeof getSongsForMalAnimeIds>>,
};
}
// This endpoint proxies MAL and works server-side.
const malUrl = new URL(
`/api/mal/animelist/${encodeURIComponent(username)}`,
url.origin,
);
malUrl.searchParams.set("limit", String(LIST_QUERY_LIMIT));
if (status) malUrl.searchParams.set("status", status);
// NOTE: If you later want to support sort/offset, add them here from SearchSchema too.
const malRes = await fetch(malUrl);
if (!malRes.ok) {
// Let +page.svelte decide how to display errors; throw to use SvelteKit error page
throw new Error(`MAL request failed (${malRes.status})`);
}
const malJson: unknown = await malRes.json();
const malResponse = MalAnimeListResponseSchema.parse(malJson);
// Client-only DB: on the server `db` is null, so return [] and let hydration re-run load in browser.
if (!db) {
return {
LIST_QUERY_LIMIT,
username,
status: status ?? null,
malResponse,
songRows: [] as Awaited<ReturnType<typeof getSongsForMalAnimeIds>>,
};
}
// Browser path: seed then query local DB for songs by MAL ids
await ensureSeeded({ fetch });
const malIds = malResponse.data.map((e) => e.node.id);
const songRows = await getSongsForMalAnimeIds(db, malIds);
return {
LIST_QUERY_LIMIT,
username,
status: status ?? null,
malResponse,
songRows,
};
};

View File

@@ -0,0 +1,8 @@
import { AnimeListKind, AnimeListWatchStatus } from "$lib/utils/list";
import { z } from "zod";
export const SearchParamsSchema = z.object({
kind: AnimeListKind.default("mal"),
username: z.string().default(""),
status: z.array(AnimeListWatchStatus).default([]),
})

226
src/routes/mal/+page.svelte Normal file
View File

@@ -0,0 +1,226 @@
<script lang="ts">
import { useSearchParams } from "runed/kit";
import { onMount } from "svelte";
import { z } from "zod";
import { browser } from "$app/environment";
import { invalidate } from "$app/navigation";
import SongEntry from "$lib/components/SongEntry.svelte";
import { db as clientDb } from "$lib/db/client-db";
import { player } from "$lib/player/store.svelte";
import { trackFromSongRow } from "$lib/player/types";
import {
MalAnimeListQuerySchema,
MalAnimeListStatusEnum,
} from "$lib/types/mal";
import type { PageData } from "./$types";
const ListSearchSchema = MalAnimeListQuerySchema.extend({
// Allow empty string to mean "All"
status: MalAnimeListStatusEnum.or(z.literal("")).default(""),
// URL param `mal` is updated only on Search
mal: z.string().default(""),
}).strict();
const params = useSearchParams(ListSearchSchema, {
pushHistory: false,
showDefaults: false,
});
let { data }: { data: PageData } = $props();
// Local username field that does NOT update the URL as you type.
let formMal = $state<string>(params.mal);
// If SSR returned no songRows (because client DB wasn't available),
// re-run load on the client once the DB is ready by invalidating.
onMount(() => {
if (data.songRows.length > 0) return;
if (!data.username || !data.malResponse) return;
if (clientDb) {
void invalidate("clientdb:songs");
return;
}
});
function songArtistLabel(r: (typeof data.songRows)[number]) {
return r.artistName ?? r.groupName ?? null;
}
function makeMalHref(username: string) {
return `https://myanimelist.net/profile/${encodeURIComponent(username)}`;
}
const tracksFromResults = $derived.by(() =>
data.songRows
.map((r) =>
trackFromSongRow({
annSongId: r.annSongId,
animeName: r.animeName,
type: r.type,
number: r.number,
songName: r.songName,
artistName: songArtistLabel(r),
fileName: r.fileName,
dub: Boolean(r.dub),
rebroadcast: Boolean(r.rebroadcast),
globalPercent: r.globalPercent,
}),
)
.filter((t) => t !== null),
);
</script>
<h1 class="text-2xl font-semibold">MAL List → Songs</h1>
<p class="mt-2 text-sm text-muted-foreground">
{#if !clientDb}
Loading DB...
{/if}
</p>
<form
class="mt-4 flex flex-col gap-2"
onsubmit={(e) => {
e.preventDefault();
params.mal = formMal;
}}
>
<div class="flex flex-wrap gap-2">
<div class="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"
bind:value={formMal}
autocomplete="off"
spellcheck={false}
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground" for="mal-status"
>Status</label
>
<select
id="mal-status"
class="rounded border px-3 py-2 text-sm"
bind:value={params.status}
>
<option value="">All</option>
<option value="watching">Watching</option>
<option value="completed">Completed</option>
<option value="on_hold">On hold</option>
<option value="dropped">Dropped</option>
<option value="plan_to_watch">Plan to watch</option>
</select>
</div>
<div class="flex flex-col justify-end">
<button
type="submit"
class="rounded border px-3 py-2 text-sm"
disabled={!(formMal ?? "").trim()}
>
Search
</button>
</div>
</div>
<div class="text-sm text-muted-foreground">
{#if data.username}
MAL entries: {data.malResponse?.data.length ?? 0} (limited to {data.LIST_QUERY_LIMIT})
• Songs found: {data.songRows.length}
{/if}
</div>
{#if data.songRows.length > 0}
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded border px-3 py-2 text-sm"
onclick={() => player.addAll(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Add all to queue
</button>
<button
type="button"
class="rounded border px-3 py-2 text-sm"
onclick={() => player.playAllNext(tracksFromResults)}
disabled={tracksFromResults.length === 0}
>
Play all next
</button>
{#if tracksFromResults.length !== data.songRows.length}
<span class="self-center text-sm text-muted-foreground">
({tracksFromResults.length} playable)
</span>
{/if}
</div>
{/if}
{#if data.username}
<div class="text-sm">
<a
class="hover:underline"
href={makeMalHref(data.username)}
target="_blank"
rel="noreferrer"
>
View {data.username} on MAL
</a>
</div>
{/if}
</form>
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) === 0}
<p class="mt-4 text-sm text-muted-foreground">
No anime returned from MAL (did you set a restrictive status?).
</p>
{/if}
{#if (formMal ?? "").trim() && data.username && (data.malResponse?.data.length ?? 0) > 0 && data.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 AMQ DB.
</p>
{/if}
{#if data.songRows.length > 0}
<h2 class="mt-6 text-lg font-semibold">Songs</h2>
<ul class="mt-3 space-y-2">
{#each data.songRows as r (String(r.annId) + ":" + String(r.annSongId))}
<li>
<SongEntry
annSongId={r.annSongId}
animeName={r.animeName}
type={r.type}
number={r.number}
songName={r.songName}
artistName={songArtistLabel(r)}
fileName={r.fileName}
globalPercent={r.globalPercent}
dub={Boolean(r.dub)}
rebroadcast={Boolean(r.rebroadcast)}
/>
</li>
{/each}
</ul>
{/if}
{#if browser && data.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 !browser}
Loading stuff...
{/if}

100
src/routes/mal/+page.ts Normal file
View File

@@ -0,0 +1,100 @@
import { z } from "zod";
// Import client-db index directly as requested.
// On the server, `db` will be null (because `browser` is false in that module).
import { db, ensureSeeded, getSongsForMalAnimeIds } from "$lib/db/client-db";
import {
MalAnimeListQuerySchema,
MalAnimeListResponseSchema,
MalAnimeListStatusEnum,
} from "$lib/types/mal";
import type { PageLoad } from "./$types";
const LIST_QUERY_LIMIT = 1000;
const SearchSchema = MalAnimeListQuerySchema.extend({
// Username
mal: z.string().optional(),
// Allow empty string to mean "All"
status: MalAnimeListStatusEnum.or(z.literal("")).optional(),
}).strict();
type StatusParam = z.infer<typeof SearchSchema>["status"];
function normalizeStatus(
status: StatusParam,
): z.infer<typeof MalAnimeListStatusEnum> | undefined {
if (status == null || status === "") return undefined;
return status;
}
export const load: PageLoad = async ({ url, fetch, depends }) => {
depends("mal:animelist");
depends("clientdb:songs");
const parsed = SearchSchema.safeParse(
Object.fromEntries(url.searchParams.entries()),
);
const mal = parsed.success ? parsed.data.mal : undefined;
const status = parsed.success
? normalizeStatus(parsed.data.status)
: undefined;
const username = (mal ?? "").trim();
// Always return a stable shape for hydration
if (!username) {
return {
LIST_QUERY_LIMIT,
username: "",
status: status ?? null,
malResponse: null as z.infer<typeof MalAnimeListResponseSchema> | null,
songRows: [] as Awaited<ReturnType<typeof getSongsForMalAnimeIds>>,
};
}
// This endpoint proxies MAL and works server-side.
const malUrl = new URL(
`/api/mal/animelist/${encodeURIComponent(username)}`,
url.origin,
);
malUrl.searchParams.set("limit", String(LIST_QUERY_LIMIT));
if (status) malUrl.searchParams.set("status", status);
// NOTE: If you later want to support sort/offset, add them here from SearchSchema too.
const malRes = await fetch(malUrl);
if (!malRes.ok) {
// Let +page.svelte decide how to display errors; throw to use SvelteKit error page
throw new Error(`MAL request failed (${malRes.status})`);
}
const malJson: unknown = await malRes.json();
const malResponse = MalAnimeListResponseSchema.parse(malJson);
// Client-only DB: on the server `db` is null, so return [] and let hydration re-run load in browser.
if (!db) {
return {
LIST_QUERY_LIMIT,
username,
status: status ?? null,
malResponse,
songRows: [] as Awaited<ReturnType<typeof getSongsForMalAnimeIds>>,
};
}
// Browser path: seed then query local DB for songs by MAL ids
await ensureSeeded({ fetch });
const malIds = malResponse.data.map((e) => e.node.id);
const songRows = await getSongsForMalAnimeIds(db, malIds);
return {
LIST_QUERY_LIMIT,
username,
status: status ?? null,
malResponse,
songRows,
};
};

View File

@@ -11,14 +11,14 @@ const songTypesCodec = z.codec(z.string(), z.array(AmqSongLinkType), {
decode: (str) => decode: (str) =>
str str
? decodeURIComponent(str) ? decodeURIComponent(str)
.split(SEP) .split(SEP)
.map((s) => AmqSongLinkTypeMap[s as keyof typeof AmqSongLinkTypeMap]) .map((s) => AmqSongLinkTypeMap[s as keyof typeof AmqSongLinkTypeMap])
: [], : [],
encode: (arr) => encode: (arr) =>
arr arr
? encodeURIComponent( ? encodeURIComponent(
arr.map((a) => AmqSongLinkTypeMapReverse[a]).join(SEP), arr.map((a) => AmqSongLinkTypeMapReverse[a]).join(SEP),
) )
: "", : "",
}); });