Pre-release version v0.0.14 (#96)

* add dev branch container to workflow

* correctly set the default range of ActivityGrid

* fix: set name/short_name to koito (#61)

* fix dev container push workflow

* fix: race condition with using getComputedStyle primary color for dynamic activity grid darkening (#76)

* Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening

Instead just use the color from the current theme directly. Tested works on initial load and theme changes.
Fixes https://github.com/gabehf/Koito/issues/75

* Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name
Split name out of the Theme struct to simplify custom theme saving/reading

* fix: set first artist listed as primary by default (#81)

* feat: add server-side configuration with default theme (#90)

* docs: add example for usage of the main listenbrainz instance (#71)

* docs: add example for usage of the main listenbrainz instance

* Update scrobbler.md

---------

Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com>

* feat: add server-side cfg and default theme

* fix: repair custom theme

---------

Co-authored-by: m0d3rnX <jesper@posteo.de>

* docs: add default theme cfg option to docs

* feat: add ability to manually scrobble track (#91)

* feat: add button to manually scrobble from ui

* fix: ensure timestamp is in the past, log fix

* test: add integration test

* feat: add first listened to dates for media items (#92)

* fix: ensure error checks for ErrNoRows

* feat: add now playing endpoint and ui (#93)

* wip

* feat: add now playing

* fix: set default theme when config is not set

* feat: fetch images from subsonic server (#94)

* fix: useQuery instead of useEffect for now playing

* feat: custom artist separator regex (#95)

* Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening

Instead just use the color from the current theme directly. Tested works on initial load and theme changes.
Fixes https://github.com/gabehf/Koito/issues/75

* Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name
Split name out of the Theme struct to simplify custom theme saving/reading

* feat: add server-side configuration with default theme (#90)

* docs: add example for usage of the main listenbrainz instance (#71)

* docs: add example for usage of the main listenbrainz instance

* Update scrobbler.md

---------

Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com>

* feat: add server-side cfg and default theme

* fix: repair custom theme

---------

Co-authored-by: m0d3rnX <jesper@posteo.de>

* fix: rebase errors

---------

Co-authored-by: pet <128837728+againstpetra@users.noreply.github.com>
Co-authored-by: mlandry <mike.landry@gmail.com>
Co-authored-by: m0d3rnX <jesper@posteo.de>
This commit is contained in:
Gabe Farrell 2025-11-19 20:26:56 -05:00 committed by GitHub
parent bf0ec68cfe
commit 36f984a1a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1887 additions and 906 deletions

View file

@ -1,327 +1,419 @@
interface getItemsArgs {
limit: number,
period: string,
page: number,
artist_id?: number,
album_id?: number,
track_id?: number
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
step: string;
range: number;
month: number;
year: number;
artist_id: number;
album_id: number;
track_id: number;
}
function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> {
return 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}`).then(r => r.json() as Promise<PaginatedResponse<Listen>>)
function getLastListens(
args: getItemsArgs
): Promise<PaginatedResponse<Listen>> {
return 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}`
).then((r) => r.json() as Promise<PaginatedResponse<Listen>>);
}
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> {
if (args.artist_id) {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
} else if (args.album_id) {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
} else {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
}
if (args.artist_id) {
return fetch(
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
} else if (args.album_id) {
return fetch(
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
} else {
return fetch(
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
}
}
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> {
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`
if (args.artist_id) {
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>)
} else {
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>)
}
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
if (args.artist_id) {
return fetch(baseUri + `&artist_id=${args.artist_id}`).then(
(r) => r.json() as Promise<PaginatedResponse<Album>>
);
} else {
return fetch(baseUri).then(
(r) => r.json() as Promise<PaginatedResponse<Album>>
);
}
}
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Artist>>)
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`;
return fetch(baseUri).then(
(r) => r.json() as Promise<PaginatedResponse<Artist>>
);
}
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
return 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}`).then(r => r.json() as Promise<ListenActivityItem[]>)
return 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}`
).then((r) => r.json() as Promise<ListenActivityItem[]>);
}
function getStats(period: string): Promise<Stats> {
return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>)
return fetch(`/apis/web/v1/stats?period=${period}`).then(
(r) => r.json() as Promise<Stats>
);
}
function search(q: string): Promise<SearchResponse> {
q = encodeURIComponent(q)
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as 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}`
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,
})
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",
})
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 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 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 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",
})
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[]>)
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 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);
}
const data: ApiKey = await r.json();
return data;
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"
})
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,
})
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"
})
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,
})
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[]>)
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 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 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 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 getAlbum(id: number): Promise<Album> {
return fetch(`/apis/web/v1/album?id=${id}`).then(r => r.json() as 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"
})
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 getExport() {}
function getNowPlaying(): Promise<NowPlaying> {
return fetch("/apis/web/v1/now-playing").then((r) => r.json());
}
export {
getLastListens,
getTopTracks,
getTopAlbums,
getTopArtists,
getActivity,
getStats,
search,
replaceImage,
mergeTracks,
mergeAlbums,
mergeArtists,
imageUrl,
login,
logout,
deleteItem,
updateUser,
getAliases,
createAlias,
deleteAlias,
setPrimaryAlias,
getApiKeys,
createApiKey,
deleteApiKey,
updateApiKeyLabel,
deleteListen,
getAlbum,
getExport,
}
getLastListens,
getTopTracks,
getTopAlbums,
getTopArtists,
getActivity,
getStats,
search,
replaceImage,
mergeTracks,
mergeAlbums,
mergeArtists,
imageUrl,
login,
logout,
getCfg,
deleteItem,
updateUser,
getAliases,
createAlias,
deleteAlias,
setPrimaryAlias,
getApiKeys,
createApiKey,
deleteApiKey,
updateApiKeyLabel,
deleteListen,
getAlbum,
getExport,
submitListen,
getNowPlaying,
};
type Track = {
id: number
title: string
artists: SimpleArtists[]
listen_count: number
image: string
album_id: number
musicbrainz_id: string
time_listened: number
}
id: number;
title: string;
artists: SimpleArtists[];
listen_count: number;
image: string;
album_id: number;
musicbrainz_id: string;
time_listened: number;
first_listen: number;
};
type Artist = {
id: number
name: string
image: string,
aliases: string[]
listen_count: number
musicbrainz_id: string
time_listened: number
is_primary: boolean
}
id: number;
name: string;
image: string;
aliases: string[];
listen_count: number;
musicbrainz_id: string;
time_listened: number;
first_listen: number;
is_primary: boolean;
};
type Album = {
id: number,
title: string
image: string
listen_count: number
is_various_artists: boolean
artists: SimpleArtists[]
musicbrainz_id: string
time_listened: number
}
id: number;
title: string;
image: string;
listen_count: number;
is_various_artists: boolean;
artists: SimpleArtists[];
musicbrainz_id: string;
time_listened: number;
first_listen: number;
};
type Alias = {
id: number
alias: string
source: string
is_primary: boolean
}
id: number;
alias: string;
source: string;
is_primary: boolean;
};
type Listen = {
time: string,
track: Track,
}
time: string;
track: Track;
};
type PaginatedResponse<T> = {
items: T[],
total_record_count: number,
has_next_page: boolean,
current_page: number,
items_per_page: number,
}
items: T[];
total_record_count: number;
has_next_page: boolean;
current_page: number;
items_per_page: number;
};
type ListenActivityItem = {
start_time: Date,
listens: number
}
start_time: Date;
listens: number;
};
type SimpleArtists = {
name: string
id: number
}
name: string;
id: number;
};
type Stats = {
listen_count: number
track_count: number
album_count: number
artist_count: number
minutes_listened: number
}
listen_count: number;
track_count: number;
album_count: number;
artist_count: number;
minutes_listened: number;
};
type SearchResponse = {
albums: Album[]
artists: Artist[]
tracks: Track[]
}
albums: Album[];
artists: Artist[];
tracks: Track[];
};
type User = {
id: number
username: string
role: 'user' | 'admin'
}
id: number;
username: string;
role: "user" | "admin";
};
type ApiKey = {
id: number
key: string
label: string
created_at: Date
}
id: number;
key: string;
label: string;
created_at: Date;
};
type ApiError = {
error: string
}
error: string;
};
type Config = {
default_theme: string;
};
type NowPlaying = {
currently_playing: boolean;
track: Track;
};
export type {
getItemsArgs,
getActivityArgs,
Track,
Artist,
Album,
Listen,
SearchResponse,
PaginatedResponse,
ListenActivityItem,
User,
Alias,
ApiKey,
ApiError
}
getItemsArgs,
getActivityArgs,
Track,
Artist,
Album,
Listen,
SearchResponse,
PaginatedResponse,
ListenActivityItem,
User,
Alias,
ApiKey,
ApiError,
Config,
NowPlaying,
};