214 lines
7.3 KiB
TypeScript
214 lines
7.3 KiB
TypeScript
import { API_ENDPOINT } from "./endpoint";
|
|
import { ApiError, apiFetch } from "./client";
|
|
import type { ArchiveItem, ArchiveResult, DanimeEpisode, ScheduleResponse, ShowItem, TimeResponse } from "./types";
|
|
|
|
export type { DanimeEpisode, ScheduleResponse, ShowItem, TimeResponse, ArchiveItem } from "./types";
|
|
|
|
function asNumber(v: unknown, fallback = 0) {
|
|
const n = typeof v === "number" ? v : Number(v);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
}
|
|
|
|
function asString(v: unknown, fallback = "") {
|
|
return typeof v === "string" ? v : fallback;
|
|
}
|
|
|
|
function normalizeSchedule(data: unknown): ScheduleResponse {
|
|
if (data == null || typeof data !== "object") {
|
|
throw new ApiError("Bad schedule payload", { url: API_ENDPOINT.v1.CURRENT, data });
|
|
}
|
|
const obj = data as Record<string, unknown>;
|
|
return {
|
|
ep_num: asNumber(obj.ep_num, 0),
|
|
ep_title: asString(obj.ep_title, "未設定"),
|
|
season_name: asString(obj.season_name, "未設定"),
|
|
start_time: obj.start_time ? asString(obj.start_time) : undefined,
|
|
playback_length: obj.playback_length ? asString(obj.playback_length) : undefined,
|
|
};
|
|
}
|
|
|
|
function normalizeShows(data: unknown): ShowItem[] {
|
|
if (!Array.isArray(data)) {
|
|
throw new ApiError("Shows payload is not an array", { url: API_ENDPOINT.v1.SHOWS, data });
|
|
}
|
|
return data.map((item) => {
|
|
if (!item || typeof item !== "object") {
|
|
throw new ApiError("Bad show item", { url: API_ENDPOINT.v1.SHOWS, data: item });
|
|
}
|
|
const obj = item as Record<string, unknown>;
|
|
const id = asNumber(obj.id, NaN);
|
|
if (!Number.isFinite(id)) {
|
|
throw new ApiError("Show item missing id", { url: API_ENDPOINT.v1.SHOWS, data: item });
|
|
}
|
|
return {
|
|
id,
|
|
ep_num: asNumber(obj.ep_num, 0),
|
|
ep_title: asString(obj.ep_title, "不明"),
|
|
season_name: asString(obj.season_name, "不明"),
|
|
start_time: asString(obj.start_time, ""),
|
|
playback_length: asString(obj.playback_length, ""),
|
|
date_created: asString(obj.date_created, ""),
|
|
};
|
|
});
|
|
}
|
|
|
|
function normalizeArchive(data: unknown): ArchiveItem[] {
|
|
if (!Array.isArray(data)) {
|
|
throw new ApiError("Archive payload is not an array", { url: API_ENDPOINT.v1.ARCHIVE, data });
|
|
}
|
|
return data.map((item) => {
|
|
if (!item || typeof item !== "object") {
|
|
throw new ApiError("Bad archive item", { url: API_ENDPOINT.v1.ARCHIVE, data: item });
|
|
}
|
|
const obj = item as Record<string, unknown>;
|
|
const id = asNumber(obj.id, NaN);
|
|
if (!Number.isFinite(id)) {
|
|
throw new ApiError("Archive item missing id", { url: API_ENDPOINT.v1.ARCHIVE, data: item });
|
|
}
|
|
return {
|
|
id,
|
|
ep_num: asNumber(obj.ep_num, 0),
|
|
ep_title: asString(obj.ep_title, "不明"),
|
|
season_name: asString(obj.season_name, "不明"),
|
|
start_time: asString(obj.start_time, ""),
|
|
playback_length: asString(obj.playback_length, ""),
|
|
current_ep: Boolean(obj.current_ep),
|
|
date_created: asString(obj.date_created, ""),
|
|
date_archived: asString(obj.date_archived, ""),
|
|
};
|
|
});
|
|
}
|
|
|
|
function normalizeDanimeEpisode(data: unknown): DanimeEpisode {
|
|
if (!data || typeof data !== "object") {
|
|
throw new ApiError("Bad danime payload", { url: API_ENDPOINT.v1.DANIME, data });
|
|
}
|
|
const obj = data as Record<string, unknown>;
|
|
return {
|
|
ep_num: asNumber(obj.ep_num, 0),
|
|
ep_title: asString(obj.ep_title, ""),
|
|
season_name: asString(obj.season_name, ""),
|
|
playback_length: asString(obj.playback_length, ""),
|
|
};
|
|
}
|
|
|
|
export async function fetchSchedule(signal?: AbortSignal) {
|
|
const data = await apiFetch<ScheduleResponse>(API_ENDPOINT.v1.CURRENT, {
|
|
signal,
|
|
timeoutMs: 12_000,
|
|
logLabel: "load schedule",
|
|
});
|
|
return normalizeSchedule(data);
|
|
}
|
|
|
|
export async function fetchShows(signal?: AbortSignal) {
|
|
const data = await apiFetch<ShowItem[]>(API_ENDPOINT.v1.SHOWS, {
|
|
signal,
|
|
timeoutMs: 12_000,
|
|
logLabel: "fetch shows",
|
|
});
|
|
return normalizeShows(data);
|
|
}
|
|
|
|
export async function postCurrentEpisode(payload: { id: number; start_time?: string }, signal?: AbortSignal) {
|
|
await apiFetch<void>(API_ENDPOINT.v1.CURRENT, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
signal,
|
|
timeoutMs: 12_000,
|
|
expect: "void",
|
|
logLabel: "set current episode",
|
|
});
|
|
}
|
|
|
|
export async function fetchServerNow(signal?: AbortSignal): Promise<number> {
|
|
const data = await apiFetch<TimeResponse>(API_ENDPOINT.v1.TIME, {
|
|
signal,
|
|
timeoutMs: 8_000,
|
|
logLabel: "server time",
|
|
});
|
|
let n = typeof data?.now === "number" ? data.now : Number(data?.now);
|
|
if (!Number.isFinite(n)) {
|
|
throw new ApiError("Bad time payload", { url: API_ENDPOINT.v1.TIME, data });
|
|
}
|
|
if (n < 1e12) n = n * 1000; // seconds → ms
|
|
return n;
|
|
}
|
|
|
|
export async function fetchDanimeEpisode(partId: string, signal?: AbortSignal): Promise<DanimeEpisode> {
|
|
const url = `${API_ENDPOINT.v1.DANIME}?part_id=${encodeURIComponent(partId)}`;
|
|
const data = await apiFetch<DanimeEpisode>(url, {
|
|
signal,
|
|
timeoutMs: 12_000,
|
|
logLabel: "fetch danime episode",
|
|
});
|
|
return normalizeDanimeEpisode(data);
|
|
}
|
|
|
|
export async function createShow(payload: {
|
|
ep_num: number;
|
|
ep_title: string;
|
|
season_name: string;
|
|
playback_length: string;
|
|
start_time?: string;
|
|
}, signal?: AbortSignal) {
|
|
await apiFetch<void>(API_ENDPOINT.v1.SHOWS, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
signal,
|
|
timeoutMs: 12_000,
|
|
expect: "void",
|
|
logLabel: "create show",
|
|
});
|
|
}
|
|
|
|
export async function deleteArchiveShow(id: number, idToken: string) {
|
|
if (!idToken) {
|
|
throw new ApiError("Missing auth token for delete");
|
|
}
|
|
const url = `${API_ENDPOINT.v1.SHOWS}?id=${encodeURIComponent(id)}`;
|
|
await apiFetch<void>(url, {
|
|
method: "DELETE",
|
|
headers: {
|
|
"Authorization": `Bearer ${idToken}`,
|
|
},
|
|
timeoutMs: 10_000,
|
|
expect: "void",
|
|
logLabel: "delete archived show",
|
|
});
|
|
}
|
|
|
|
export async function archiveShow(id: number, idToken: string) {
|
|
if (!idToken) {
|
|
throw new ApiError("Missing auth token for archive");
|
|
}
|
|
await apiFetch<ArchiveResult>(API_ENDPOINT.v1.ARCHIVE, {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${idToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ ids: [id] }),
|
|
timeoutMs: 10_000,
|
|
expect: "json",
|
|
logLabel: "archive show",
|
|
});
|
|
}
|
|
|
|
export async function fetchArchive(idToken: string) {
|
|
if (!idToken) {
|
|
throw new ApiError("Missing auth token for archive list");
|
|
}
|
|
const data = await apiFetch<ArchiveItem[]>(API_ENDPOINT.v1.ARCHIVE, {
|
|
method: "GET",
|
|
headers: {
|
|
"Authorization": `Bearer ${idToken}`,
|
|
},
|
|
timeoutMs: 12_000,
|
|
logLabel: "fetch archive",
|
|
});
|
|
return normalizeArchive(data);
|
|
}
|