Files
stoat-mbot/web/src/api.ts
T
Leonid PershinandClaude Opus 5 971fd65b1f Add a source picker to search and use the full window width
Search now queries YouTube and SoundCloud together by default and
interleaves the two result lists so neither buries the other; a picker
left of the input narrows it to one source (plus the local library when
configured), and a prefix typed into the query still outranks it.

The panel was capped at 1180px, which left most of a wide screen empty —
it now scales to 1680px and gives the search column the extra room.

A dead link also reported "could not parse yt-dlp's response", which
described our parser rather than the problem; it now shows yt-dlp's own
error line, or says the link did not open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:11:29 +03:00

94 lines
3.4 KiB
TypeScript

import type { Me, ServerStateResponse, Track } from "./types";
export class ApiError extends Error {}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
credentials: "same-origin",
headers: init.body ? { "content-type": "application/json" } : undefined,
...init,
});
const text = await res.text();
const body = text ? JSON.parse(text) : null;
if (!res.ok) throw new ApiError(body?.error ?? `Ошибка ${res.status}`);
return body as T;
}
export interface LoginResponse {
user?: { id: string; username: string };
mfaRequired?: boolean;
ticket?: string;
methods?: string[];
}
export const api = {
me: () => request<Me>("/api/me"),
login: (payload: {
email?: string;
password?: string;
mfaTicket?: string;
totpCode?: string;
recoveryCode?: string;
}) => request<LoginResponse>("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }),
loginWithLink: (token: string) =>
request<{ user: { id: string; username: string }; serverId: string | null }>("/api/auth/link", {
method: "POST",
body: JSON.stringify({ token }),
}),
logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }),
state: (serverId: string) => request<ServerStateResponse>(`/api/servers/${serverId}/state`),
search: (serverId: string, query: string, source: string) =>
request<{ tracks: Track[] }>(
`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}&source=${source}`,
),
play: (
serverId: string,
payload: { query?: string; trackIds?: string[]; mode?: "append" | "next" | "now"; voiceChannelId?: string | null },
) => request<{ ok: true }>(`/api/servers/${serverId}/play`, { method: "POST", body: JSON.stringify(payload) }),
action: (serverId: string, action: string, payload: Record<string, unknown> = {}) =>
request<{ ok: true }>(`/api/servers/${serverId}/actions/${action}`, {
method: "POST",
body: JSON.stringify(payload),
}),
join: (serverId: string, voiceChannelId: string | null) =>
request<{ ok: true }>(`/api/servers/${serverId}/join`, {
method: "POST",
body: JSON.stringify({ voiceChannelId }),
}),
removeTrack: (serverId: string, trackId: string) =>
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}`, { method: "DELETE" }),
moveTrack: (serverId: string, trackId: string, index: number) =>
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}/move`, {
method: "POST",
body: JSON.stringify({ index }),
}),
};
/** Clock formatting for any position or length; 0 is a legitimate "0:00". */
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const total = Math.floor(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
const pad = (value: number) => value.toString().padStart(2, "0");
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
}
/** Track length as shown to people: live streams and unknown lengths are not clocks. */
export function formatLength(track: { duration: number; isLive: boolean }): string {
if (track.isLive) return "LIVE";
if (track.duration <= 0) return "—";
return formatDuration(track.duration);
}