Koito/client/api/api.ts
Gabe Farrell 5e294b839c
feat: all time rank display (#149)
* add all time rank to item pages

* fix artist albums component

* add no rows check

* fix rewind page
2026-01-16 01:03:23 -05:00

501 lines
11 KiB
TypeScript

interface getItemsArgs {
limit: number;
period: string;
page: number;
artist_id?: number;
album_id?: number;
track_id?: number;
}
interface getActivityArgs {
step: string;
range: number;
month: number;
year: number;
artist_id: number;
album_id: number;
track_id: number;
}
interface timeframe {
week?: number;
month?: number;
year?: number;
from?: number;
to?: number;
period?: string;
}
interface getInterestArgs {
buckets: number;
artist_id: number;
album_id: number;
track_id: number;
}
async function handleJson<T>(r: Response): Promise<T> {
if (!r.ok) {
const err = await r.json();
throw Error(err.error);
}
return (await r.json()) as T;
}
async function getLastListens(
args: getItemsArgs
): Promise<PaginatedResponse<Listen>> {
const r = await fetch(
`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`
);
return handleJson<PaginatedResponse<Listen>>(r);
}
async function getTopTracks(
args: getItemsArgs
): Promise<PaginatedResponse<Ranked<Track>>> {
let url = `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`;
if (args.artist_id) url += `&artist_id=${args.artist_id}`;
else if (args.album_id) url += `&album_id=${args.album_id}`;
const r = await fetch(url);
return handleJson<PaginatedResponse<Ranked<Track>>>(r);
}
async function getTopAlbums(
args: getItemsArgs
): Promise<PaginatedResponse<Ranked<Album>>> {
let url = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
if (args.artist_id) url += `&artist_id=${args.artist_id}`;
const r = await fetch(url);
return handleJson<PaginatedResponse<Ranked<Album>>>(r);
}
async function getTopArtists(
args: getItemsArgs
): Promise<PaginatedResponse<Ranked<Artist>>> {
const url = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`;
const r = await fetch(url);
return handleJson<PaginatedResponse<Ranked<Artist>>>(r);
}
async function getActivity(
args: getActivityArgs
): Promise<ListenActivityItem[]> {
const r = await fetch(
`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
);
return handleJson<ListenActivityItem[]>(r);
}
async function getInterest(args: getInterestArgs): Promise<InterestBucket[]> {
const r = await fetch(
`/apis/web/v1/interest?buckets=${args.buckets}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
);
return handleJson<InterestBucket[]>(r);
}
async function getStats(period: string): Promise<Stats> {
const r = await fetch(`/apis/web/v1/stats?period=${period}`);
return handleJson<Stats>(r);
}
function search(q: string): Promise<SearchResponse> {
q = encodeURIComponent(q);
return fetch(`/apis/web/v1/search?q=${q}`).then(
(r) => r.json() as Promise<SearchResponse>
);
}
function imageUrl(id: string, size: string) {
if (!id) {
id = "default";
}
return `/images/${size}/${id}`;
}
function replaceImage(form: FormData): Promise<Response> {
return fetch(`/apis/web/v1/replace-image`, {
method: "POST",
body: form,
});
}
function mergeTracks(from: number, to: number): Promise<Response> {
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
method: "POST",
});
}
function mergeAlbums(
from: number,
to: number,
replaceImage: boolean
): Promise<Response> {
return fetch(
`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
{
method: "POST",
}
);
}
function mergeArtists(
from: number,
to: number,
replaceImage: boolean
): Promise<Response> {
return fetch(
`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
{
method: "POST",
}
);
}
function login(
username: string,
password: string,
remember: boolean
): Promise<Response> {
const form = new URLSearchParams();
form.append("username", username);
form.append("password", password);
form.append("remember_me", String(remember));
return fetch(`/apis/web/v1/login`, {
method: "POST",
body: form,
});
}
function logout(): Promise<Response> {
return fetch(`/apis/web/v1/logout`, {
method: "POST",
});
}
function getCfg(): Promise<Config> {
return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise<Config>);
}
function submitListen(id: string, ts: Date): Promise<Response> {
const form = new URLSearchParams();
form.append("track_id", id);
const ms = new Date(ts).getTime();
const unix = Math.floor(ms / 1000);
form.append("unix", unix.toString());
return fetch(`/apis/web/v1/listen`, {
method: "POST",
body: form,
});
}
function getApiKeys(): Promise<ApiKey[]> {
return fetch(`/apis/web/v1/user/apikeys`).then(
(r) => r.json() as Promise<ApiKey[]>
);
}
const createApiKey = async (label: string): Promise<ApiKey> => {
const form = new URLSearchParams();
form.append("label", label);
const r = await fetch(`/apis/web/v1/user/apikeys`, {
method: "POST",
body: form,
});
if (!r.ok) {
let errorMessage = `error: ${r.status}`;
try {
const errorData: ApiError = await r.json();
if (errorData && typeof errorData.error === "string") {
errorMessage = errorData.error;
}
} catch (e) {
console.error("unexpected api error:", e);
}
throw new Error(errorMessage);
}
const data: ApiKey = await r.json();
return data;
};
function deleteApiKey(id: number): Promise<Response> {
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
method: "DELETE",
});
}
function updateApiKeyLabel(id: number, label: string): Promise<Response> {
const form = new URLSearchParams();
form.append("id", String(id));
form.append("label", label);
return fetch(`/apis/web/v1/user/apikeys`, {
method: "PATCH",
body: form,
});
}
function deleteItem(itemType: string, id: number): Promise<Response> {
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
method: "DELETE",
});
}
function updateUser(username: string, password: string) {
const form = new URLSearchParams();
form.append("username", username);
form.append("password", password);
return fetch(`/apis/web/v1/user`, {
method: "PATCH",
body: form,
});
}
function getAliases(type: string, id: number): Promise<Alias[]> {
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(
(r) => r.json() as Promise<Alias[]>
);
}
function createAlias(
type: string,
id: number,
alias: string
): Promise<Response> {
const form = new URLSearchParams();
form.append(`${type}_id`, String(id));
form.append("alias", alias);
return fetch(`/apis/web/v1/aliases`, {
method: "POST",
body: form,
});
}
function deleteAlias(
type: string,
id: number,
alias: string
): Promise<Response> {
const form = new URLSearchParams();
form.append(`${type}_id`, String(id));
form.append("alias", alias);
return fetch(`/apis/web/v1/aliases/delete`, {
method: "POST",
body: form,
});
}
function setPrimaryAlias(
type: string,
id: number,
alias: string
): Promise<Response> {
const form = new URLSearchParams();
form.append(`${type}_id`, String(id));
form.append("alias", alias);
return fetch(`/apis/web/v1/aliases/primary`, {
method: "POST",
body: form,
});
}
function updateMbzId(
type: string,
id: number,
mbzid: string
): Promise<Response> {
const form = new URLSearchParams();
form.append(`${type}_id`, String(id));
form.append("mbz_id", mbzid);
return fetch(`/apis/web/v1/mbzid`, {
method: "PATCH",
body: form,
});
}
function getAlbum(id: number): Promise<Album> {
return fetch(`/apis/web/v1/album?id=${id}`).then(
(r) => r.json() as Promise<Album>
);
}
function deleteListen(listen: Listen): Promise<Response> {
const ms = new Date(listen.time).getTime();
const unix = Math.floor(ms / 1000);
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
method: "DELETE",
});
}
function getExport() {}
function getNowPlaying(): Promise<NowPlaying> {
return fetch("/apis/web/v1/now-playing").then((r) => r.json());
}
async function getRewindStats(args: timeframe): Promise<RewindStats> {
const r = await fetch(
`/apis/web/v1/summary?week=${args.week}&month=${args.month}&year=${args.year}&from=${args.from}&to=${args.to}`
);
return handleJson<RewindStats>(r);
}
export {
getLastListens,
getTopTracks,
getTopAlbums,
getTopArtists,
getActivity,
getInterest,
getStats,
search,
replaceImage,
mergeTracks,
mergeAlbums,
mergeArtists,
imageUrl,
login,
logout,
getCfg,
deleteItem,
updateUser,
getAliases,
createAlias,
deleteAlias,
setPrimaryAlias,
updateMbzId,
getApiKeys,
createApiKey,
deleteApiKey,
updateApiKeyLabel,
deleteListen,
getAlbum,
getExport,
submitListen,
getNowPlaying,
getRewindStats,
};
type Track = {
id: number;
title: string;
artists: SimpleArtists[];
listen_count: number;
image: string;
album_id: number;
musicbrainz_id: string;
time_listened: number;
first_listen: number;
all_time_rank: number;
};
type Artist = {
id: number;
name: string;
image: string;
aliases: string[];
listen_count: number;
musicbrainz_id: string;
time_listened: number;
first_listen: number;
is_primary: boolean;
all_time_rank: number;
};
type Album = {
id: number;
title: string;
image: string;
listen_count: number;
is_various_artists: boolean;
artists: SimpleArtists[];
musicbrainz_id: string;
time_listened: number;
first_listen: number;
all_time_rank: number;
};
type Alias = {
id: number;
alias: string;
source: string;
is_primary: boolean;
};
type Listen = {
time: string;
track: Track;
};
type PaginatedResponse<T> = {
items: T[];
total_record_count: number;
has_next_page: boolean;
current_page: number;
items_per_page: number;
};
type Ranked<T> = {
item: T;
rank: number;
};
type ListenActivityItem = {
start_time: Date;
listens: number;
};
type InterestBucket = {
bucket_start: Date;
bucket_end: Date;
listen_count: number;
};
type SimpleArtists = {
name: string;
id: number;
};
type Stats = {
listen_count: number;
track_count: number;
album_count: number;
artist_count: number;
minutes_listened: number;
};
type SearchResponse = {
albums: Album[];
artists: Artist[];
tracks: Track[];
};
type User = {
id: number;
username: string;
role: "user" | "admin";
};
type ApiKey = {
id: number;
key: string;
label: string;
created_at: Date;
};
type ApiError = {
error: string;
};
type Config = {
default_theme: string;
};
type NowPlaying = {
currently_playing: boolean;
track: Track;
};
type RewindStats = {
title: string;
top_artists: Ranked<Artist>[];
top_albums: Ranked<Album>[];
top_tracks: Ranked<Track>[];
minutes_listened: number;
avg_minutes_listened_per_day: number;
plays: number;
avg_plays_per_day: number;
unique_tracks: number;
unique_albums: number;
unique_artists: number;
new_tracks: number;
new_albums: number;
new_artists: number;
};
export type {
getItemsArgs,
getActivityArgs,
getInterestArgs,
Track,
Artist,
Album,
Listen,
SearchResponse,
PaginatedResponse,
Ranked,
ListenActivityItem,
InterestBucket,
User,
Alias,
ApiKey,
ApiError,
Config,
NowPlaying,
Stats,
RewindStats,
};