Backend: add refresh JWT (30d, token_type claim), POST /auth/refresh endpoint (rotates token pair), remember_me on login, JWT_REFRESH_EXPIRY_DAYS env var. Extractors now reject refresh tokens on protected routes. Frontend: sessionStorage for non-remembered sessions, localStorage + refresh token for remembered sessions. Transparent 401 recovery in api.ts (retry once after refresh). Remember me checkbox on login page with security note when checked.
337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
import type {
|
|
TokenResponse,
|
|
UserResponse,
|
|
ConfigResponse,
|
|
ChannelResponse,
|
|
CreateChannelRequest,
|
|
UpdateChannelRequest,
|
|
ScheduleResponse,
|
|
ScheduledSlotResponse,
|
|
CurrentBroadcastResponse,
|
|
CollectionResponse,
|
|
SeriesResponse,
|
|
LibraryItemResponse,
|
|
MediaFilter,
|
|
TranscodeSettings,
|
|
TranscodeStats,
|
|
ActivityEvent,
|
|
ProviderConfig,
|
|
ProviderTestResult,
|
|
ConfigSnapshot,
|
|
ScheduleHistoryEntry,
|
|
} from "@/lib/types";
|
|
|
|
const API_BASE =
|
|
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000/api/v1";
|
|
|
|
export class ApiRequestError extends Error {
|
|
constructor(
|
|
public readonly status: number,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "ApiRequestError";
|
|
}
|
|
}
|
|
|
|
// Called by AuthProvider when refreshToken changes — enables transparent 401 recovery
|
|
let refreshCallback: (() => Promise<string | null>) | null = null;
|
|
let refreshInFlight: Promise<string | null> | null = null;
|
|
|
|
export function setRefreshCallback(cb: (() => Promise<string | null>) | null) {
|
|
refreshCallback = cb;
|
|
}
|
|
|
|
async function attemptRefresh(): Promise<string | null> {
|
|
if (!refreshCallback) return null;
|
|
if (refreshInFlight) return refreshInFlight;
|
|
refreshInFlight = refreshCallback().finally(() => {
|
|
refreshInFlight = null;
|
|
});
|
|
return refreshInFlight;
|
|
}
|
|
|
|
async function request<T>(
|
|
path: string,
|
|
options: RequestInit & { token?: string } = {},
|
|
): Promise<T> {
|
|
const { token, ...init } = options;
|
|
const headers = new Headers(init.headers);
|
|
|
|
if (token) {
|
|
headers.set("Authorization", `Bearer ${token}`);
|
|
}
|
|
if (init.body && !headers.has("Content-Type")) {
|
|
headers.set("Content-Type", "application/json");
|
|
}
|
|
|
|
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
|
|
|
// Transparent refresh: on 401, try to get a new access token and retry once.
|
|
// Skip for the refresh endpoint itself to avoid infinite loops.
|
|
if (res.status === 401 && path !== "/auth/refresh") {
|
|
const newToken = await attemptRefresh();
|
|
if (newToken) {
|
|
const retryHeaders = new Headers(init.headers);
|
|
retryHeaders.set("Authorization", `Bearer ${newToken}`);
|
|
if (init.body && !retryHeaders.has("Content-Type")) {
|
|
retryHeaders.set("Content-Type", "application/json");
|
|
}
|
|
const retryRes = await fetch(`${API_BASE}${path}`, {
|
|
...init,
|
|
headers: retryHeaders,
|
|
});
|
|
if (!retryRes.ok) {
|
|
let message = retryRes.statusText;
|
|
try {
|
|
const body = await retryRes.json();
|
|
message = body.message ?? body.error ?? message;
|
|
} catch {
|
|
// ignore parse error
|
|
}
|
|
throw new ApiRequestError(retryRes.status, message);
|
|
}
|
|
if (retryRes.status === 204) return null as T;
|
|
return retryRes.json() as Promise<T>;
|
|
}
|
|
}
|
|
|
|
if (!res.ok) {
|
|
let message = res.statusText;
|
|
try {
|
|
const body = await res.json();
|
|
message = body.message ?? body.error ?? message;
|
|
} catch {
|
|
// ignore parse error, use statusText
|
|
}
|
|
throw new ApiRequestError(res.status, message);
|
|
}
|
|
|
|
if (res.status === 204) return null as T;
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
export const api = {
|
|
config: {
|
|
get: () => request<ConfigResponse>("/config"),
|
|
},
|
|
|
|
auth: {
|
|
register: (email: string, password: string) =>
|
|
request<TokenResponse>("/auth/register", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email, password }),
|
|
}),
|
|
|
|
login: (email: string, password: string, rememberMe = false) =>
|
|
request<TokenResponse>("/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email, password, remember_me: rememberMe }),
|
|
}),
|
|
|
|
refresh: (refreshToken: string) =>
|
|
request<TokenResponse>("/auth/refresh", {
|
|
method: "POST",
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
}),
|
|
|
|
logout: (token: string) =>
|
|
request<void>("/auth/logout", { method: "POST", token }),
|
|
|
|
me: (token: string) => request<UserResponse>("/auth/me", { token }),
|
|
},
|
|
|
|
channels: {
|
|
list: (token: string) =>
|
|
request<ChannelResponse[]>("/channels", { token }),
|
|
|
|
get: (id: string, token: string) =>
|
|
request<ChannelResponse>(`/channels/${id}`, { token }),
|
|
|
|
create: (data: CreateChannelRequest, token: string) =>
|
|
request<ChannelResponse>("/channels", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
token,
|
|
}),
|
|
|
|
update: (id: string, data: UpdateChannelRequest, token: string) =>
|
|
request<ChannelResponse>(`/channels/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
token,
|
|
}),
|
|
|
|
delete: (id: string, token: string) =>
|
|
request<void>(`/channels/${id}`, { method: "DELETE", token }),
|
|
|
|
listConfigHistory: (channelId: string, token: string) =>
|
|
request<ConfigSnapshot[]>(`/channels/${channelId}/config/history`, { token }),
|
|
|
|
patchConfigSnapshot: (channelId: string, snapId: string, label: string | null, token: string) =>
|
|
request<ConfigSnapshot>(`/channels/${channelId}/config/history/${snapId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ label }),
|
|
token,
|
|
}),
|
|
|
|
restoreConfigSnapshot: (channelId: string, snapId: string, token: string) =>
|
|
request<ChannelResponse>(`/channels/${channelId}/config/history/${snapId}/restore`, {
|
|
method: "POST",
|
|
token,
|
|
}),
|
|
|
|
listScheduleHistory: (channelId: string, token: string) =>
|
|
request<ScheduleHistoryEntry[]>(`/channels/${channelId}/schedule/history`, { token }),
|
|
|
|
getScheduleGeneration: (channelId: string, genId: string, token: string) =>
|
|
request<ScheduleResponse>(`/channels/${channelId}/schedule/history/${genId}`, { token }),
|
|
|
|
rollbackSchedule: (channelId: string, genId: string, token: string) =>
|
|
request<ScheduleResponse>(`/channels/${channelId}/schedule/history/${genId}/rollback`, {
|
|
method: "POST",
|
|
token,
|
|
}),
|
|
},
|
|
|
|
library: {
|
|
collections: (token: string, provider?: string) => {
|
|
const params = new URLSearchParams();
|
|
if (provider) params.set("provider", provider);
|
|
const qs = params.toString();
|
|
return request<CollectionResponse[]>(`/library/collections${qs ? `?${qs}` : ""}`, { token });
|
|
},
|
|
|
|
series: (token: string, collectionId?: string, provider?: string) => {
|
|
const params = new URLSearchParams();
|
|
if (collectionId) params.set("collection", collectionId);
|
|
if (provider) params.set("provider", provider);
|
|
const qs = params.toString();
|
|
return request<SeriesResponse[]>(`/library/series${qs ? `?${qs}` : ""}`, { token });
|
|
},
|
|
|
|
genres: (token: string, contentType?: string, provider?: string) => {
|
|
const params = new URLSearchParams();
|
|
if (contentType) params.set("type", contentType);
|
|
if (provider) params.set("provider", provider);
|
|
const qs = params.toString();
|
|
return request<string[]>(`/library/genres${qs ? `?${qs}` : ""}`, { token });
|
|
},
|
|
|
|
items: (
|
|
token: string,
|
|
filter: Pick<MediaFilter, "content_type" | "series_names" | "collections" | "search_term" | "genres">,
|
|
limit = 50,
|
|
strategy?: string,
|
|
provider?: string,
|
|
) => {
|
|
const params = new URLSearchParams();
|
|
if (filter.search_term) params.set("q", filter.search_term);
|
|
if (filter.content_type) params.set("type", filter.content_type);
|
|
filter.series_names?.forEach((name) => params.append("series[]", name));
|
|
if (filter.collections?.[0]) params.set("collection", filter.collections[0]);
|
|
params.set("limit", String(limit));
|
|
if (strategy) params.set("strategy", strategy);
|
|
if (provider) params.set("provider", provider);
|
|
return request<LibraryItemResponse[]>(`/library/items?${params}`, { token });
|
|
},
|
|
},
|
|
|
|
files: {
|
|
rescan: (token: string) =>
|
|
request<{ items_found: number }>("/files/rescan", { method: "POST", token }),
|
|
},
|
|
|
|
transcode: {
|
|
getSettings: (token: string) =>
|
|
request<TranscodeSettings>("/files/transcode-settings", { token }),
|
|
|
|
updateSettings: (data: TranscodeSettings, token: string) =>
|
|
request<TranscodeSettings>("/files/transcode-settings", {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
token,
|
|
}),
|
|
|
|
getStats: (token: string) =>
|
|
request<TranscodeStats>("/files/transcode-stats", { token }),
|
|
|
|
clearCache: (token: string) =>
|
|
request<void>("/files/transcode-cache", { method: "DELETE", token }),
|
|
},
|
|
|
|
admin: {
|
|
activity: (token: string) =>
|
|
request<ActivityEvent[]>("/admin/activity", { token }),
|
|
|
|
providers: {
|
|
getProviders: (token: string) =>
|
|
request<ProviderConfig[]>("/admin/providers", { token }),
|
|
|
|
updateProvider: (
|
|
token: string,
|
|
type: string,
|
|
payload: { config_json: Record<string, string>; enabled: boolean },
|
|
) =>
|
|
request<ProviderConfig>(`/admin/providers/${type}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(payload),
|
|
token,
|
|
}),
|
|
|
|
deleteProvider: (token: string, type: string) =>
|
|
request<void>(`/admin/providers/${type}`, { method: "DELETE", token }),
|
|
|
|
testProvider: (
|
|
token: string,
|
|
type: string,
|
|
payload: { config_json: Record<string, string>; enabled: boolean },
|
|
) =>
|
|
request<ProviderTestResult>(`/admin/providers/${type}/test`, {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
token,
|
|
}),
|
|
},
|
|
},
|
|
|
|
schedule: {
|
|
generate: (channelId: string, token: string) =>
|
|
request<ScheduleResponse>(`/channels/${channelId}/schedule`, {
|
|
method: "POST",
|
|
token,
|
|
}),
|
|
|
|
getActive: (channelId: string, token: string) =>
|
|
request<ScheduleResponse>(`/channels/${channelId}/schedule`, { token }),
|
|
|
|
getCurrentBroadcast: (channelId: string, token: string, channelPassword?: string) => {
|
|
const headers: Record<string, string> = {};
|
|
if (channelPassword) headers["X-Channel-Password"] = channelPassword;
|
|
return request<CurrentBroadcastResponse | null>(`/channels/${channelId}/now`, {
|
|
token,
|
|
headers,
|
|
});
|
|
},
|
|
|
|
getEpg: (
|
|
channelId: string,
|
|
token: string,
|
|
from?: string,
|
|
until?: string,
|
|
channelPassword?: string,
|
|
) => {
|
|
const params = new URLSearchParams();
|
|
if (from) params.set("from", from);
|
|
if (until) params.set("until", until);
|
|
const qs = params.toString();
|
|
const headers: Record<string, string> = {};
|
|
if (channelPassword) headers["X-Channel-Password"] = channelPassword;
|
|
return request<ScheduledSlotResponse[]>(
|
|
`/channels/${channelId}/epg${qs ? `?${qs}` : ""}`,
|
|
{ token, headers },
|
|
);
|
|
},
|
|
},
|
|
};
|