409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
import type {
|
|
TokenResponse,
|
|
UserResponse,
|
|
ConfigResponse,
|
|
ChannelResponse,
|
|
CreateChannelRequest,
|
|
UpdateChannelRequest,
|
|
ScheduleResponse,
|
|
ScheduledSlotResponse,
|
|
CurrentBroadcastResponse,
|
|
CollectionResponse,
|
|
SeriesResponse,
|
|
LibraryItemResponse,
|
|
MediaFilter,
|
|
TranscodeSettings,
|
|
TranscodeStats,
|
|
ActivityEvent,
|
|
ProviderConfig,
|
|
ProviderTestResult,
|
|
ConfigSnapshot,
|
|
ScheduleHistoryEntry,
|
|
LibrarySyncLogEntry,
|
|
PagedLibraryResponse,
|
|
AdminSettings,
|
|
ShowSummary,
|
|
SeasonSummary,
|
|
} 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 });
|
|
},
|
|
|
|
syncStatus: (token: string): Promise<LibrarySyncLogEntry[]> =>
|
|
request('/library/sync/status', { token }),
|
|
|
|
triggerSync: (token: string): Promise<void> =>
|
|
request('/library/sync', { method: 'POST', token }),
|
|
|
|
itemsPage: (
|
|
token: string,
|
|
filter: Partial<{
|
|
q: string; type: string; series: string[]; genres: string[]; collection: string;
|
|
provider: string; decade: number; min_duration: number; max_duration: number;
|
|
offset: number; limit: number;
|
|
}>
|
|
): Promise<PagedLibraryResponse> => {
|
|
const params = new URLSearchParams();
|
|
if (filter.q) params.set('q', filter.q);
|
|
if (filter.type) params.set('type', filter.type);
|
|
if (filter.series) filter.series.forEach(s => params.append('series[]', s));
|
|
if (filter.genres) filter.genres.forEach(g => params.append('genres[]', g));
|
|
if (filter.collection) params.set('collection', filter.collection);
|
|
if (filter.provider) params.set('provider', filter.provider);
|
|
if (filter.decade != null) params.set('decade', String(filter.decade));
|
|
if (filter.min_duration != null) params.set('min_duration', String(filter.min_duration));
|
|
if (filter.max_duration != null) params.set('max_duration', String(filter.max_duration));
|
|
params.set('offset', String(filter.offset ?? 0));
|
|
params.set('limit', String(filter.limit ?? 50));
|
|
return request(`/library/items?${params}`, { token });
|
|
},
|
|
|
|
shows: (token: string, filter?: { q?: string; provider?: string; genres?: string[] }): Promise<ShowSummary[]> => {
|
|
const params = new URLSearchParams();
|
|
if (filter?.q) params.set('q', filter.q);
|
|
if (filter?.provider) params.set('provider', filter.provider);
|
|
filter?.genres?.forEach(g => params.append('genres[]', g));
|
|
const qs = params.toString();
|
|
return request(`/library/shows${qs ? `?${qs}` : ''}`, { token });
|
|
},
|
|
|
|
seasons: (token: string, seriesName: string, provider?: string): Promise<SeasonSummary[]> => {
|
|
const params = new URLSearchParams();
|
|
if (provider) params.set('provider', provider);
|
|
const qs = params.toString();
|
|
return request(`/library/shows/${encodeURIComponent(seriesName)}/seasons${qs ? `?${qs}` : ''}`, { token });
|
|
},
|
|
},
|
|
|
|
files: {
|
|
rescan: (token: string, provider?: string) => {
|
|
const qs = provider ? `?provider=${encodeURIComponent(provider)}` : "";
|
|
return request<{ items_found: number }>(`/files/rescan${qs}`, { 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 }),
|
|
|
|
getSettings: (token: string): Promise<AdminSettings> =>
|
|
request('/admin/settings', { token }),
|
|
|
|
updateSettings: (token: string, patch: Partial<AdminSettings>): Promise<AdminSettings> =>
|
|
request('/admin/settings', {
|
|
method: 'PUT',
|
|
token,
|
|
body: JSON.stringify(patch),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
|
|
providers: {
|
|
getProviders: (token: string) =>
|
|
request<ProviderConfig[]>("/admin/providers", { token }),
|
|
|
|
createProvider: (
|
|
token: string,
|
|
payload: { id: string; provider_type: string; config_json: Record<string, string>; enabled: boolean },
|
|
) =>
|
|
request<ProviderConfig>("/admin/providers", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
token,
|
|
}),
|
|
|
|
updateProvider: (
|
|
token: string,
|
|
id: string,
|
|
payload: { config_json: Record<string, string>; enabled: boolean },
|
|
) =>
|
|
request<ProviderConfig>(`/admin/providers/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(payload),
|
|
token,
|
|
}),
|
|
|
|
deleteProvider: (token: string, id: string) =>
|
|
request<void>(`/admin/providers/${id}`, { method: "DELETE", token }),
|
|
|
|
testProvider: (
|
|
token: string,
|
|
payload: { provider_type: string; config_json: Record<string, string> },
|
|
) =>
|
|
request<ProviderTestResult>("/admin/providers/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 },
|
|
);
|
|
},
|
|
},
|
|
};
|