mirror of https://github.com/gabehf/Koito.git
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>pull/98/head v0.0.14
parent
bf0ec68cfe
commit
36f984a1a2
@ -1,327 +1,419 @@
|
|||||||
interface getItemsArgs {
|
interface getItemsArgs {
|
||||||
limit: number,
|
limit: number;
|
||||||
period: string,
|
period: string;
|
||||||
page: number,
|
page: number;
|
||||||
artist_id?: number,
|
artist_id?: number;
|
||||||
album_id?: number,
|
album_id?: number;
|
||||||
track_id?: number
|
track_id?: number;
|
||||||
}
|
}
|
||||||
interface getActivityArgs {
|
interface getActivityArgs {
|
||||||
step: string
|
step: string;
|
||||||
range: number
|
range: number;
|
||||||
month: number
|
month: number;
|
||||||
year: number
|
year: number;
|
||||||
artist_id: number
|
artist_id: number;
|
||||||
album_id: number
|
album_id: number;
|
||||||
track_id: number
|
track_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> {
|
function getLastListens(
|
||||||
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>>)
|
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>> {
|
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> {
|
||||||
if (args.artist_id) {
|
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>>)
|
return fetch(
|
||||||
} else if (args.album_id) {
|
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`
|
||||||
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>>)
|
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
|
||||||
} else {
|
} else if (args.album_id) {
|
||||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
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>> {
|
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> {
|
||||||
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
|
||||||
if (args.artist_id) {
|
if (args.artist_id) {
|
||||||
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>)
|
return fetch(baseUri + `&artist_id=${args.artist_id}`).then(
|
||||||
} else {
|
(r) => r.json() as Promise<PaginatedResponse<Album>>
|
||||||
return fetch(baseUri).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>> {
|
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
|
||||||
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
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>>)
|
return fetch(baseUri).then(
|
||||||
|
(r) => r.json() as Promise<PaginatedResponse<Artist>>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
|
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> {
|
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> {
|
function search(q: string): Promise<SearchResponse> {
|
||||||
q = encodeURIComponent(q)
|
q = encodeURIComponent(q);
|
||||||
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>)
|
return fetch(`/apis/web/v1/search?q=${q}`).then(
|
||||||
|
(r) => r.json() as Promise<SearchResponse>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageUrl(id: string, size: string) {
|
function imageUrl(id: string, size: string) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
id = 'default'
|
id = "default";
|
||||||
}
|
}
|
||||||
return `/images/${size}/${id}`
|
return `/images/${size}/${id}`;
|
||||||
}
|
}
|
||||||
function replaceImage(form: FormData): Promise<Response> {
|
function replaceImage(form: FormData): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/replace-image`, {
|
return fetch(`/apis/web/v1/replace-image`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeTracks(from: number, to: number): Promise<Response> {
|
function mergeTracks(from: number, to: number): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
|
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise<Response> {
|
function mergeAlbums(
|
||||||
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, {
|
from: number,
|
||||||
method: "POST",
|
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 mergeArtists(from: number, to: number, replaceImage: boolean): Promise<Response> {
|
function logout(): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, {
|
return fetch(`/apis/web/v1/logout`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function login(username: string, password: string, remember: boolean): Promise<Response> {
|
|
||||||
const form = new URLSearchParams
|
function getCfg(): Promise<Config> {
|
||||||
form.append('username', username)
|
return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise<Config>);
|
||||||
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`, {
|
function submitListen(id: string, ts: Date): Promise<Response> {
|
||||||
method: "POST",
|
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[]> {
|
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 createApiKey = async (label: string): Promise<ApiKey> => {
|
||||||
const form = new URLSearchParams
|
const form = new URLSearchParams();
|
||||||
form.append('label', label)
|
form.append("label", label);
|
||||||
const r = await fetch(`/apis/web/v1/user/apikeys`, {
|
const r = await fetch(`/apis/web/v1/user/apikeys`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: form,
|
body: form,
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
let errorMessage = `error: ${r.status}`;
|
let errorMessage = `error: ${r.status}`;
|
||||||
try {
|
try {
|
||||||
const errorData: ApiError = await r.json();
|
const errorData: ApiError = await r.json();
|
||||||
if (errorData && typeof errorData.error === 'string') {
|
if (errorData && typeof errorData.error === "string") {
|
||||||
errorMessage = errorData.error;
|
errorMessage = errorData.error;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("unexpected api error:", e);
|
console.error("unexpected api error:", e);
|
||||||
}
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
}
|
||||||
const data: ApiKey = await r.json();
|
throw new Error(errorMessage);
|
||||||
return data;
|
}
|
||||||
|
const data: ApiKey = await r.json();
|
||||||
|
return data;
|
||||||
};
|
};
|
||||||
function deleteApiKey(id: number): Promise<Response> {
|
function deleteApiKey(id: number): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
|
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
|
||||||
method: "DELETE"
|
method: "DELETE",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function updateApiKeyLabel(id: number, label: string): Promise<Response> {
|
function updateApiKeyLabel(id: number, label: string): Promise<Response> {
|
||||||
const form = new URLSearchParams
|
const form = new URLSearchParams();
|
||||||
form.append('id', String(id))
|
form.append("id", String(id));
|
||||||
form.append('label', label)
|
form.append("label", label);
|
||||||
return fetch(`/apis/web/v1/user/apikeys`, {
|
return fetch(`/apis/web/v1/user/apikeys`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: form,
|
body: form,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteItem(itemType: string, id: number): Promise<Response> {
|
function deleteItem(itemType: string, id: number): Promise<Response> {
|
||||||
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
|
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
|
||||||
method: "DELETE"
|
method: "DELETE",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function updateUser(username: string, password: string) {
|
function updateUser(username: string, password: string) {
|
||||||
const form = new URLSearchParams
|
const form = new URLSearchParams();
|
||||||
form.append('username', username)
|
form.append("username", username);
|
||||||
form.append('password', password)
|
form.append("password", password);
|
||||||
return fetch(`/apis/web/v1/user`, {
|
return fetch(`/apis/web/v1/user`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: form,
|
body: form,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function getAliases(type: string, id: number): Promise<Alias[]> {
|
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))
|
function createAlias(
|
||||||
form.append('alias', alias)
|
type: string,
|
||||||
return fetch(`/apis/web/v1/aliases`, {
|
id: number,
|
||||||
method: 'POST',
|
alias: string
|
||||||
body: form,
|
): Promise<Response> {
|
||||||
})
|
const form = new URLSearchParams();
|
||||||
}
|
form.append(`${type}_id`, String(id));
|
||||||
function deleteAlias(type: string, id: number, alias: string): Promise<Response> {
|
form.append("alias", alias);
|
||||||
const form = new URLSearchParams
|
return fetch(`/apis/web/v1/aliases`, {
|
||||||
form.append(`${type}_id`, String(id))
|
method: "POST",
|
||||||
form.append('alias', alias)
|
body: form,
|
||||||
return fetch(`/apis/web/v1/aliases/delete`, {
|
});
|
||||||
method: "POST",
|
}
|
||||||
body: form,
|
function deleteAlias(
|
||||||
})
|
type: string,
|
||||||
}
|
id: number,
|
||||||
function setPrimaryAlias(type: string, id: number, alias: string): Promise<Response> {
|
alias: string
|
||||||
const form = new URLSearchParams
|
): Promise<Response> {
|
||||||
form.append(`${type}_id`, String(id))
|
const form = new URLSearchParams();
|
||||||
form.append('alias', alias)
|
form.append(`${type}_id`, String(id));
|
||||||
return fetch(`/apis/web/v1/aliases/primary`, {
|
form.append("alias", alias);
|
||||||
method: "POST",
|
return fetch(`/apis/web/v1/aliases/delete`, {
|
||||||
body: form,
|
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> {
|
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> {
|
function deleteListen(listen: Listen): Promise<Response> {
|
||||||
const ms = new Date(listen.time).getTime()
|
const ms = new Date(listen.time).getTime();
|
||||||
const unix= Math.floor(ms / 1000);
|
const unix = Math.floor(ms / 1000);
|
||||||
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
|
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
|
||||||
method: "DELETE"
|
method: "DELETE",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
function getExport() {
|
function getExport() {}
|
||||||
|
|
||||||
|
function getNowPlaying(): Promise<NowPlaying> {
|
||||||
|
return fetch("/apis/web/v1/now-playing").then((r) => r.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getLastListens,
|
getLastListens,
|
||||||
getTopTracks,
|
getTopTracks,
|
||||||
getTopAlbums,
|
getTopAlbums,
|
||||||
getTopArtists,
|
getTopArtists,
|
||||||
getActivity,
|
getActivity,
|
||||||
getStats,
|
getStats,
|
||||||
search,
|
search,
|
||||||
replaceImage,
|
replaceImage,
|
||||||
mergeTracks,
|
mergeTracks,
|
||||||
mergeAlbums,
|
mergeAlbums,
|
||||||
mergeArtists,
|
mergeArtists,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
deleteItem,
|
getCfg,
|
||||||
updateUser,
|
deleteItem,
|
||||||
getAliases,
|
updateUser,
|
||||||
createAlias,
|
getAliases,
|
||||||
deleteAlias,
|
createAlias,
|
||||||
setPrimaryAlias,
|
deleteAlias,
|
||||||
getApiKeys,
|
setPrimaryAlias,
|
||||||
createApiKey,
|
getApiKeys,
|
||||||
deleteApiKey,
|
createApiKey,
|
||||||
updateApiKeyLabel,
|
deleteApiKey,
|
||||||
deleteListen,
|
updateApiKeyLabel,
|
||||||
getAlbum,
|
deleteListen,
|
||||||
getExport,
|
getAlbum,
|
||||||
}
|
getExport,
|
||||||
|
submitListen,
|
||||||
|
getNowPlaying,
|
||||||
|
};
|
||||||
type Track = {
|
type Track = {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
artists: SimpleArtists[]
|
artists: SimpleArtists[];
|
||||||
listen_count: number
|
listen_count: number;
|
||||||
image: string
|
image: string;
|
||||||
album_id: number
|
album_id: number;
|
||||||
musicbrainz_id: string
|
musicbrainz_id: string;
|
||||||
time_listened: number
|
time_listened: number;
|
||||||
}
|
first_listen: number;
|
||||||
|
};
|
||||||
type Artist = {
|
type Artist = {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
image: string,
|
image: string;
|
||||||
aliases: string[]
|
aliases: string[];
|
||||||
listen_count: number
|
listen_count: number;
|
||||||
musicbrainz_id: string
|
musicbrainz_id: string;
|
||||||
time_listened: number
|
time_listened: number;
|
||||||
is_primary: boolean
|
first_listen: number;
|
||||||
}
|
is_primary: boolean;
|
||||||
|
};
|
||||||
type Album = {
|
type Album = {
|
||||||
id: number,
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
image: string
|
image: string;
|
||||||
listen_count: number
|
listen_count: number;
|
||||||
is_various_artists: boolean
|
is_various_artists: boolean;
|
||||||
artists: SimpleArtists[]
|
artists: SimpleArtists[];
|
||||||
musicbrainz_id: string
|
musicbrainz_id: string;
|
||||||
time_listened: number
|
time_listened: number;
|
||||||
}
|
first_listen: number;
|
||||||
|
};
|
||||||
type Alias = {
|
type Alias = {
|
||||||
id: number
|
id: number;
|
||||||
alias: string
|
alias: string;
|
||||||
source: string
|
source: string;
|
||||||
is_primary: boolean
|
is_primary: boolean;
|
||||||
}
|
};
|
||||||
type Listen = {
|
type Listen = {
|
||||||
time: string,
|
time: string;
|
||||||
track: Track,
|
track: Track;
|
||||||
}
|
};
|
||||||
type PaginatedResponse<T> = {
|
type PaginatedResponse<T> = {
|
||||||
items: T[],
|
items: T[];
|
||||||
total_record_count: number,
|
total_record_count: number;
|
||||||
has_next_page: boolean,
|
has_next_page: boolean;
|
||||||
current_page: number,
|
current_page: number;
|
||||||
items_per_page: number,
|
items_per_page: number;
|
||||||
}
|
};
|
||||||
type ListenActivityItem = {
|
type ListenActivityItem = {
|
||||||
start_time: Date,
|
start_time: Date;
|
||||||
listens: number
|
listens: number;
|
||||||
}
|
};
|
||||||
type SimpleArtists = {
|
type SimpleArtists = {
|
||||||
name: string
|
name: string;
|
||||||
id: number
|
id: number;
|
||||||
}
|
};
|
||||||
type Stats = {
|
type Stats = {
|
||||||
listen_count: number
|
listen_count: number;
|
||||||
track_count: number
|
track_count: number;
|
||||||
album_count: number
|
album_count: number;
|
||||||
artist_count: number
|
artist_count: number;
|
||||||
minutes_listened: number
|
minutes_listened: number;
|
||||||
}
|
};
|
||||||
type SearchResponse = {
|
type SearchResponse = {
|
||||||
albums: Album[]
|
albums: Album[];
|
||||||
artists: Artist[]
|
artists: Artist[];
|
||||||
tracks: Track[]
|
tracks: Track[];
|
||||||
}
|
};
|
||||||
type User = {
|
type User = {
|
||||||
id: number
|
id: number;
|
||||||
username: string
|
username: string;
|
||||||
role: 'user' | 'admin'
|
role: "user" | "admin";
|
||||||
}
|
};
|
||||||
type ApiKey = {
|
type ApiKey = {
|
||||||
id: number
|
id: number;
|
||||||
key: string
|
key: string;
|
||||||
label: string
|
label: string;
|
||||||
created_at: Date
|
created_at: Date;
|
||||||
}
|
};
|
||||||
type ApiError = {
|
type ApiError = {
|
||||||
error: string
|
error: string;
|
||||||
}
|
};
|
||||||
|
type Config = {
|
||||||
|
default_theme: string;
|
||||||
|
};
|
||||||
|
type NowPlaying = {
|
||||||
|
currently_playing: boolean;
|
||||||
|
track: Track;
|
||||||
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
getItemsArgs,
|
getItemsArgs,
|
||||||
getActivityArgs,
|
getActivityArgs,
|
||||||
Track,
|
Track,
|
||||||
Artist,
|
Artist,
|
||||||
Album,
|
Album,
|
||||||
Listen,
|
Listen,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
ListenActivityItem,
|
ListenActivityItem,
|
||||||
User,
|
User,
|
||||||
Alias,
|
Alias,
|
||||||
ApiKey,
|
ApiKey,
|
||||||
ApiError
|
ApiError,
|
||||||
}
|
Config,
|
||||||
|
NowPlaying,
|
||||||
|
};
|
||||||
|
|||||||
@ -1,200 +1,197 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api"
|
import {
|
||||||
import Popup from "./Popup"
|
getActivity,
|
||||||
import { useEffect, useState } from "react"
|
type getActivityArgs,
|
||||||
import { useTheme } from "~/hooks/useTheme"
|
type ListenActivityItem,
|
||||||
import ActivityOptsSelector from "./ActivityOptsSelector"
|
} from "api/api";
|
||||||
|
import Popup from "./Popup";
|
||||||
function getPrimaryColor(): string {
|
import { useState } from "react";
|
||||||
const value = getComputedStyle(document.documentElement)
|
import { useTheme } from "~/hooks/useTheme";
|
||||||
.getPropertyValue('--color-primary')
|
import ActivityOptsSelector from "./ActivityOptsSelector";
|
||||||
.trim();
|
import type { Theme } from "~/styles/themes.css";
|
||||||
|
|
||||||
const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
|
function getPrimaryColor(theme: Theme): string {
|
||||||
if (rgbMatch) {
|
const value = theme.primary;
|
||||||
const [, r, g, b] = rgbMatch.map(Number);
|
const rgbMatch = value.match(
|
||||||
return (
|
/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/
|
||||||
'#' +
|
);
|
||||||
[r, g, b]
|
if (rgbMatch) {
|
||||||
.map((n) => n.toString(16).padStart(2, '0'))
|
const [, r, g, b] = rgbMatch.map(Number);
|
||||||
.join('')
|
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
return value;
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
step?: string
|
step?: string;
|
||||||
range?: number
|
range?: number;
|
||||||
month?: number
|
month?: number;
|
||||||
year?: number
|
year?: number;
|
||||||
artistId?: number
|
artistId?: number;
|
||||||
albumId?: number
|
albumId?: number;
|
||||||
trackId?: number
|
trackId?: number;
|
||||||
configurable?: boolean
|
configurable?: boolean;
|
||||||
autoAdjust?: boolean
|
autoAdjust?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityGrid({
|
export default function ActivityGrid({
|
||||||
step = 'day',
|
step = "day",
|
||||||
range = 182,
|
range = 182,
|
||||||
month = 0,
|
month = 0,
|
||||||
year = 0,
|
year = 0,
|
||||||
artistId = 0,
|
artistId = 0,
|
||||||
albumId = 0,
|
albumId = 0,
|
||||||
trackId = 0,
|
trackId = 0,
|
||||||
configurable = false,
|
configurable = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [stepState, setStep] = useState(step);
|
||||||
const [color, setColor] = useState(getPrimaryColor())
|
const [rangeState, setRange] = useState(range);
|
||||||
const [stepState, setStep] = useState(step)
|
|
||||||
const [rangeState, setRange] = useState(range)
|
const { isPending, isError, data, error } = useQuery({
|
||||||
|
queryKey: [
|
||||||
const { isPending, isError, data, error } = useQuery({
|
"listen-activity",
|
||||||
queryKey: [
|
{
|
||||||
'listen-activity',
|
step: stepState,
|
||||||
{
|
range: rangeState,
|
||||||
step: stepState,
|
month: month,
|
||||||
range: rangeState,
|
year: year,
|
||||||
month: month,
|
artist_id: artistId,
|
||||||
year: year,
|
album_id: albumId,
|
||||||
artist_id: artistId,
|
track_id: trackId,
|
||||||
album_id: albumId,
|
},
|
||||||
track_id: trackId
|
],
|
||||||
},
|
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
||||||
],
|
});
|
||||||
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
|
||||||
});
|
const { theme, themeName } = useTheme();
|
||||||
|
const color = getPrimaryColor(theme);
|
||||||
|
|
||||||
const { theme } = useTheme();
|
if (isPending) {
|
||||||
useEffect(() => {
|
return (
|
||||||
const raf = requestAnimationFrame(() => {
|
<div className="w-[500px]">
|
||||||
const color = getPrimaryColor()
|
<h2>Activity</h2>
|
||||||
setColor(color);
|
<p>Loading...</p>
|
||||||
});
|
</div>
|
||||||
|
);
|
||||||
return () => cancelAnimationFrame(raf);
|
}
|
||||||
}, [theme]);
|
if (isError) return <p className="error">Error:{error.message}</p>;
|
||||||
|
|
||||||
if (isPending) {
|
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/
|
||||||
return (
|
function LightenDarkenColor(hex: string, lum: number) {
|
||||||
<div className="w-[500px]">
|
// validate hex string
|
||||||
<h2>Activity</h2>
|
hex = String(hex).replace(/[^0-9a-f]/gi, "");
|
||||||
<p>Loading...</p>
|
if (hex.length < 6) {
|
||||||
</div>
|
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (isError) return <p className="error">Error:{error.message}</p>
|
lum = lum || 0;
|
||||||
|
|
||||||
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/
|
// convert to decimal and change luminosity
|
||||||
function LightenDarkenColor(hex: string, lum: number) {
|
var rgb = "#",
|
||||||
// validate hex string
|
c,
|
||||||
hex = String(hex).replace(/[^0-9a-f]/gi, '');
|
i;
|
||||||
if (hex.length < 6) {
|
for (i = 0; i < 3; i++) {
|
||||||
hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
c = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||||
}
|
c = Math.round(Math.min(Math.max(0, c + c * lum), 255)).toString(16);
|
||||||
lum = lum || 0;
|
rgb += ("00" + c).substring(c.length);
|
||||||
|
|
||||||
// convert to decimal and change luminosity
|
|
||||||
var rgb = "#", c, i;
|
|
||||||
for (i = 0; i < 3; i++) {
|
|
||||||
c = parseInt(hex.substring(i*2,(i*2)+2), 16);
|
|
||||||
c = Math.round(Math.min(Math.max(0, c + (c * lum)), 255)).toString(16);
|
|
||||||
rgb += ("00"+c).substring(c.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rgb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDarkenAmount = (v: number, t: number): number => {
|
return rgb;
|
||||||
|
}
|
||||||
// really ugly way to just check if this is for all items and not a specific item.
|
|
||||||
// is it jsut better to just pass the target in as a var? probably.
|
const getDarkenAmount = (v: number, t: number): number => {
|
||||||
const adjustment = artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1
|
// really ugly way to just check if this is for all items and not a specific item.
|
||||||
|
// is it jsut better to just pass the target in as a var? probably.
|
||||||
// automatically adjust the target value based on step
|
const adjustment =
|
||||||
// the smartest way to do this would be to have the api return the
|
artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1;
|
||||||
// highest value in the range. too bad im not smart
|
|
||||||
switch (stepState) {
|
// automatically adjust the target value based on step
|
||||||
case 'day':
|
// the smartest way to do this would be to have the api return the
|
||||||
t = 10 * adjustment
|
// highest value in the range. too bad im not smart
|
||||||
break;
|
switch (stepState) {
|
||||||
case 'week':
|
case "day":
|
||||||
t = 20 * adjustment
|
t = 10 * adjustment;
|
||||||
break;
|
break;
|
||||||
case 'month':
|
case "week":
|
||||||
t = 50 * adjustment
|
t = 20 * adjustment;
|
||||||
break;
|
break;
|
||||||
case 'year':
|
case "month":
|
||||||
t = 100 * adjustment
|
t = 50 * adjustment;
|
||||||
break;
|
break;
|
||||||
}
|
case "year":
|
||||||
|
t = 100 * adjustment;
|
||||||
v = Math.min(v, t)
|
break;
|
||||||
if (theme === "pearl") {
|
|
||||||
// special case for the only light theme lol
|
|
||||||
// could be generalized by pragmatically comparing the
|
|
||||||
// lightness of the bg vs the primary but eh
|
|
||||||
return ((t-v) / t)
|
|
||||||
} else {
|
|
||||||
return ((v-t) / t) * .8
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHUNK_SIZE = 26 * 7;
|
v = Math.min(v, t);
|
||||||
const chunks = [];
|
if (themeName === "pearl") {
|
||||||
|
// special case for the only light theme lol
|
||||||
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
|
// could be generalized by pragmatically comparing the
|
||||||
chunks.push(data.slice(i, i + CHUNK_SIZE));
|
// lightness of the bg vs the primary but eh
|
||||||
|
return (t - v) / t;
|
||||||
|
} else {
|
||||||
|
return ((v - t) / t) * 0.8;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-start">
|
const CHUNK_SIZE = 26 * 7;
|
||||||
<h2>Activity</h2>
|
const chunks = [];
|
||||||
{configurable ? (
|
|
||||||
<ActivityOptsSelector
|
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
|
||||||
rangeSetter={setRange}
|
chunks.push(data.slice(i, i + CHUNK_SIZE));
|
||||||
currentRange={rangeState}
|
}
|
||||||
stepSetter={setStep}
|
|
||||||
currentStep={stepState}
|
return (
|
||||||
/>
|
<div className="flex flex-col items-start">
|
||||||
) : null}
|
<h2>Activity</h2>
|
||||||
|
{configurable ? (
|
||||||
{chunks.map((chunk, index) => (
|
<ActivityOptsSelector
|
||||||
|
rangeSetter={setRange}
|
||||||
|
currentRange={rangeState}
|
||||||
|
stepSetter={setStep}
|
||||||
|
currentStep={stepState}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{chunks.map((chunk, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px] mb-4"
|
||||||
|
>
|
||||||
|
{chunk.map((item) => (
|
||||||
|
<div
|
||||||
|
key={new Date(item.start_time).toString()}
|
||||||
|
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
|
||||||
|
>
|
||||||
|
<Popup
|
||||||
|
position="top"
|
||||||
|
space={12}
|
||||||
|
extraClasses="left-2"
|
||||||
|
inner={`${new Date(item.start_time).toLocaleDateString()} ${
|
||||||
|
item.listens
|
||||||
|
} plays`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
key={index}
|
style={{
|
||||||
className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px] mb-4"
|
display: "inline-block",
|
||||||
>
|
background:
|
||||||
{chunk.map((item) => (
|
item.listens > 0
|
||||||
<div
|
? LightenDarkenColor(
|
||||||
key={new Date(item.start_time).toString()}
|
color,
|
||||||
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
|
getDarkenAmount(item.listens, 100)
|
||||||
>
|
)
|
||||||
<Popup
|
: "var(--color-bg-secondary)",
|
||||||
position="top"
|
}}
|
||||||
space={12}
|
className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${
|
||||||
extraClasses="left-2"
|
item.listens > 0
|
||||||
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
|
? ""
|
||||||
>
|
: "border-[0.5px] border-(--color-bg-tertiary)"
|
||||||
<div
|
}`}
|
||||||
style={{
|
></div>
|
||||||
display: 'inline-block',
|
</Popup>
|
||||||
background:
|
</div>
|
||||||
item.listens > 0
|
))}
|
||||||
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
|
|
||||||
: 'var(--color-bg-secondary)',
|
|
||||||
}}
|
|
||||||
className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${
|
|
||||||
item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'
|
|
||||||
}`}
|
|
||||||
></div>
|
|
||||||
</Popup>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
import { AsyncButton } from "../AsyncButton";
|
||||||
|
import { submitListen } from "api/api";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
setOpen: Function
|
||||||
|
trackid: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddListenModal({ open, setOpen, trackid }: Props) {
|
||||||
|
const [ts, setTS] = useState<Date>(new Date);
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
setLoading(true)
|
||||||
|
submitListen(trackid.toString(), ts)
|
||||||
|
.then(r => {
|
||||||
|
if(r.ok) {
|
||||||
|
setLoading(false)
|
||||||
|
navigate(0)
|
||||||
|
} else {
|
||||||
|
r.json().then(r => setError(r.error))
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatForDatetimeLocal = (d: Date) => {
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={open} onClose={close}>
|
||||||
|
<h2>Add Listen</h2>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
|
value={formatForDatetimeLocal(ts)}
|
||||||
|
onChange={(e) => setTS(new Date(e.target.value))}
|
||||||
|
/>
|
||||||
|
<AsyncButton loading={loading} onClick={submit}>Submit</AsyncButton>
|
||||||
|
<p className="error">{error}</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,69 +1,78 @@
|
|||||||
// ThemeSwitcher.tsx
|
import { useState } from "react";
|
||||||
import { useEffect, useState } from 'react';
|
import { useTheme } from "../../hooks/useTheme";
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import themes from "~/styles/themes.css";
|
||||||
import themes from '~/styles/themes.css';
|
import ThemeOption from "./ThemeOption";
|
||||||
import ThemeOption from './ThemeOption';
|
import { AsyncButton } from "../AsyncButton";
|
||||||
import { AsyncButton } from '../AsyncButton';
|
|
||||||
|
|
||||||
export function ThemeSwitcher() {
|
export function ThemeSwitcher() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
const initialTheme = {
|
const initialTheme = {
|
||||||
bg: "#1e1816",
|
bg: "#1e1816",
|
||||||
bgSecondary: "#2f2623",
|
bgSecondary: "#2f2623",
|
||||||
bgTertiary: "#453733",
|
bgTertiary: "#453733",
|
||||||
fg: "#f8f3ec",
|
fg: "#f8f3ec",
|
||||||
fgSecondary: "#d6ccc2",
|
fgSecondary: "#d6ccc2",
|
||||||
fgTertiary: "#b4a89c",
|
fgTertiary: "#b4a89c",
|
||||||
primary: "#f5a97f",
|
primary: "#f5a97f",
|
||||||
primaryDim: "#d88b65",
|
primaryDim: "#d88b65",
|
||||||
accent: "#f9db6d",
|
accent: "#f9db6d",
|
||||||
accentDim: "#d9bc55",
|
accentDim: "#d9bc55",
|
||||||
error: "#e26c6a",
|
error: "#e26c6a",
|
||||||
warning: "#f5b851",
|
warning: "#f5b851",
|
||||||
success: "#8fc48f",
|
success: "#8fc48f",
|
||||||
info: "#87b8dd",
|
info: "#87b8dd",
|
||||||
}
|
};
|
||||||
|
|
||||||
const { setCustomTheme, getCustomTheme } = useTheme()
|
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme();
|
||||||
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " "))
|
const [custom, setCustom] = useState(
|
||||||
|
JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")
|
||||||
|
);
|
||||||
|
|
||||||
const handleCustomTheme = () => {
|
const handleCustomTheme = () => {
|
||||||
console.log(custom)
|
console.log(custom);
|
||||||
try {
|
try {
|
||||||
const theme = JSON.parse(custom)
|
const themeData = JSON.parse(custom);
|
||||||
theme.name = "custom"
|
setCustomTheme(themeData);
|
||||||
setCustomTheme(theme)
|
setCustom(JSON.stringify(themeData, null, " "));
|
||||||
delete theme.name
|
console.log(themeData);
|
||||||
setCustom(JSON.stringify(theme, null, " "))
|
} catch (err) {
|
||||||
console.log(theme)
|
console.log(err);
|
||||||
} catch(err) {
|
}
|
||||||
console.log(err)
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
if (theme) {
|
<div className="flex flex-col gap-10">
|
||||||
setTheme(theme)
|
<div>
|
||||||
}
|
<div className="flex items-center gap-3">
|
||||||
}, [theme]);
|
<h2>Select Theme</h2>
|
||||||
|
<div className="mb-3">
|
||||||
return (
|
<AsyncButton onClick={resetTheme}>Reset</AsyncButton>
|
||||||
<div className='flex flex-col gap-10'>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<h2>Select Theme</h2>
|
<div className="grid grid-cols-2 items-center gap-2">
|
||||||
<div className="grid grid-cols-2 items-center gap-2">
|
{Object.entries(themes).map(([name, themeData]) => (
|
||||||
{themes.map((t) => (
|
<ThemeOption
|
||||||
<ThemeOption setTheme={setTheme} key={t.name} theme={t} />
|
setTheme={setTheme}
|
||||||
))}
|
key={name}
|
||||||
</div>
|
theme={themeData}
|
||||||
</div>
|
themeName={name}
|
||||||
<div>
|
/>
|
||||||
<h2>Use Custom Theme</h2>
|
))}
|
||||||
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
</div>
|
||||||
<textarea name="custom-theme" onChange={(e) => setCustom(e.target.value)} id="custom-theme-input" className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md" value={custom} />
|
</div>
|
||||||
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
<div>
|
||||||
</div>
|
<h2>Use Custom Theme</h2>
|
||||||
</div>
|
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
||||||
|
<textarea
|
||||||
|
name="custom-theme"
|
||||||
|
onChange={(e) => setCustom(e.target.value)}
|
||||||
|
id="custom-theme-input"
|
||||||
|
className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md"
|
||||||
|
value={custom}
|
||||||
|
/>
|
||||||
|
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,95 +1,131 @@
|
|||||||
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react';
|
import {
|
||||||
import { type Theme } from '~/styles/themes.css';
|
createContext,
|
||||||
import { themeVars } from '~/styles/vars.css';
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { type Theme, themes } from "~/styles/themes.css";
|
||||||
|
import { themeVars } from "~/styles/vars.css";
|
||||||
|
import { useAppContext } from "./AppProvider";
|
||||||
|
|
||||||
interface ThemeContextValue {
|
interface ThemeContextValue {
|
||||||
theme: string;
|
themeName: string;
|
||||||
setTheme: (theme: string) => void;
|
theme: Theme;
|
||||||
setCustomTheme: (theme: Theme) => void;
|
setTheme: (theme: string) => void;
|
||||||
getCustomTheme: () => Theme | undefined;
|
resetTheme: () => void;
|
||||||
|
setCustomTheme: (theme: Theme) => void;
|
||||||
|
getCustomTheme: () => Theme | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||||
|
|
||||||
function toKebabCase(str: string) {
|
function toKebabCase(str: string) {
|
||||||
return str.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
|
return str.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCustomThemeVars(theme: Theme) {
|
function applyCustomThemeVars(theme: Theme) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
for (const [key, value] of Object.entries(theme)) {
|
for (const [key, value] of Object.entries(theme)) {
|
||||||
if (key === 'name') continue;
|
if (key === "name") continue;
|
||||||
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCustomThemeVars() {
|
function clearCustomThemeVars() {
|
||||||
for (const cssVar of Object.values(themeVars)) {
|
for (const cssVar of Object.values(themeVars)) {
|
||||||
document.documentElement.style.removeProperty(cssVar);
|
document.documentElement.style.removeProperty(cssVar);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({
|
function getStoredCustomTheme(): Theme | undefined {
|
||||||
theme: initialTheme,
|
const themeStr = localStorage.getItem("custom-theme");
|
||||||
children,
|
if (!themeStr) return undefined;
|
||||||
}: {
|
try {
|
||||||
theme: string;
|
const parsed = JSON.parse(themeStr);
|
||||||
children: ReactNode;
|
const { name, ...theme } = parsed;
|
||||||
}) {
|
return theme as Theme;
|
||||||
const [theme, setThemeName] = useState(initialTheme);
|
} catch {
|
||||||
|
return undefined;
|
||||||
const setTheme = (theme: string) => {
|
}
|
||||||
setThemeName(theme)
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
let defaultTheme = useAppContext().defaultTheme;
|
||||||
|
let initialTheme = localStorage.getItem("theme") ?? defaultTheme;
|
||||||
|
const [themeName, setThemeName] = useState(initialTheme);
|
||||||
|
const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
|
||||||
|
if (initialTheme === "custom") {
|
||||||
|
const customTheme = getStoredCustomTheme();
|
||||||
|
return customTheme || themes[defaultTheme];
|
||||||
|
}
|
||||||
|
return themes[initialTheme] || themes[defaultTheme];
|
||||||
|
});
|
||||||
|
|
||||||
|
const setTheme = (newThemeName: string) => {
|
||||||
|
setThemeName(newThemeName);
|
||||||
|
if (newThemeName === "custom") {
|
||||||
|
const customTheme = getStoredCustomTheme();
|
||||||
|
if (customTheme) {
|
||||||
|
setCurrentTheme(customTheme);
|
||||||
|
} else {
|
||||||
|
// Fallback to default theme if no custom theme found
|
||||||
|
setThemeName(defaultTheme);
|
||||||
|
setCurrentTheme(themes[defaultTheme]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const foundTheme = themes[newThemeName];
|
||||||
|
if (foundTheme) {
|
||||||
|
localStorage.setItem("theme", newThemeName);
|
||||||
|
setCurrentTheme(foundTheme);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTheme = () => {
|
||||||
|
setThemeName(defaultTheme);
|
||||||
|
localStorage.removeItem("theme");
|
||||||
|
setCurrentTheme(themes[defaultTheme]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCustomTheme = useCallback((customTheme: Theme) => {
|
||||||
|
localStorage.setItem("custom-theme", JSON.stringify(customTheme));
|
||||||
|
applyCustomThemeVars(customTheme);
|
||||||
|
setThemeName("custom");
|
||||||
|
localStorage.setItem("theme", "custom");
|
||||||
|
setCurrentTheme(customTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getCustomTheme = (): Theme | undefined => {
|
||||||
|
return getStoredCustomTheme();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
root.setAttribute("data-theme", themeName);
|
||||||
|
|
||||||
const setCustomTheme = useCallback((customTheme: Theme) => {
|
if (themeName === "custom") {
|
||||||
localStorage.setItem('custom-theme', JSON.stringify(customTheme));
|
applyCustomThemeVars(currentTheme);
|
||||||
applyCustomThemeVars(customTheme);
|
} else {
|
||||||
setTheme('custom');
|
clearCustomThemeVars();
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getCustomTheme = (): Theme | undefined => {
|
|
||||||
const themeStr = localStorage.getItem('custom-theme');
|
|
||||||
if (!themeStr) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
let theme = JSON.parse(themeStr) as Theme
|
|
||||||
return theme
|
|
||||||
} catch (err) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [themeName, currentTheme]);
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
const root = document.documentElement;
|
<ThemeContext.Provider
|
||||||
|
value={{
|
||||||
root.setAttribute('data-theme', theme);
|
themeName,
|
||||||
localStorage.setItem('theme', theme)
|
theme: currentTheme,
|
||||||
console.log(theme)
|
setTheme,
|
||||||
|
resetTheme,
|
||||||
if (theme === 'custom') {
|
setCustomTheme,
|
||||||
const saved = localStorage.getItem('custom-theme');
|
getCustomTheme,
|
||||||
if (saved) {
|
}}
|
||||||
try {
|
>
|
||||||
const parsed = JSON.parse(saved) as Theme;
|
{children}
|
||||||
applyCustomThemeVars(parsed);
|
</ThemeContext.Provider>
|
||||||
} catch (err) {
|
);
|
||||||
console.error('Invalid custom theme in localStorage', err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setTheme('yuu')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
clearCustomThemeVars()
|
|
||||||
}
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider value={{ theme, setTheme, setCustomTheme, getCustomTheme }}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ThemeContext };
|
export { ThemeContext };
|
||||||
|
|||||||
@ -0,0 +1,77 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/engine/middleware"
|
||||||
|
"github.com/gabehf/koito/internal/db"
|
||||||
|
"github.com/gabehf/koito/internal/logger"
|
||||||
|
"github.com/gabehf/koito/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SubmitListenWithIDHandler(store db.DB) http.HandlerFunc {
|
||||||
|
|
||||||
|
var defaultClientStr = "Koito Web UI"
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
|
||||||
|
l.Debug().Msg("SubmitListenWithIDHandler: Got request")
|
||||||
|
|
||||||
|
u := middleware.GetUserFromContext(ctx)
|
||||||
|
if u == nil {
|
||||||
|
l.Debug().Msg("SubmitListenWithIDHandler: Unauthorized request (user context is nil)")
|
||||||
|
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
l.Debug().Msg("SubmitListenWithIDHandler: Failed to parse form")
|
||||||
|
utils.WriteError(w, "form is invalid", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trackIDStr := r.FormValue("track_id")
|
||||||
|
timestampStr := r.FormValue("unix")
|
||||||
|
client := r.FormValue("client")
|
||||||
|
if client == "" {
|
||||||
|
client = defaultClientStr
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackIDStr == "" || timestampStr == "" {
|
||||||
|
l.Debug().Msg("SubmitListenWithIDHandler: Request is missing required parameters")
|
||||||
|
utils.WriteError(w, "track_id and unix (timestamp) must be provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trackID, err := strconv.Atoi(trackIDStr)
|
||||||
|
if err != nil {
|
||||||
|
l.Debug().AnErr("error", err).Msg("SubmitListenWithIDHandler: Invalid track id")
|
||||||
|
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unix, err := strconv.ParseInt(timestampStr, 10, 64)
|
||||||
|
if err != nil || time.Now().Unix() < unix {
|
||||||
|
l.Debug().AnErr("error", err).Msg("SubmitListenWithIDHandler: Invalid unix timestamp")
|
||||||
|
utils.WriteError(w, "invalid timestamp", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := time.Unix(unix, 0)
|
||||||
|
err = store.SaveListen(ctx, db.SaveListenOpts{
|
||||||
|
TrackID: int32(trackID),
|
||||||
|
Time: ts,
|
||||||
|
UserID: u.ID,
|
||||||
|
Client: client,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msg("SubmitListenWithIDHandler: Failed to submit listen")
|
||||||
|
utils.WriteError(w, "failed to submit listen", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/internal/db"
|
||||||
|
"github.com/gabehf/koito/internal/logger"
|
||||||
|
"github.com/gabehf/koito/internal/memkv"
|
||||||
|
"github.com/gabehf/koito/internal/models"
|
||||||
|
"github.com/gabehf/koito/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NowPlayingResponse struct {
|
||||||
|
CurrentlyPlaying bool `json:"currently_playing"`
|
||||||
|
Track models.Track `json:"track"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NowPlayingHandler(store db.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
|
||||||
|
l.Debug().Msg("NowPlayingHandler: Got request")
|
||||||
|
|
||||||
|
// Hardcoded user id as 1. Not great but it works until (if) multi-user is supported.
|
||||||
|
if trackIdI, ok := memkv.Store.Get("1"); !ok {
|
||||||
|
utils.WriteJSON(w, http.StatusOK, NowPlayingResponse{CurrentlyPlaying: false})
|
||||||
|
} else if trackId, ok := trackIdI.(int32); !ok {
|
||||||
|
l.Debug().Msg("NowPlayingHandler: Failed type assertion for trackIdI")
|
||||||
|
utils.WriteError(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
|
track, err := store.GetTrack(ctx, db.GetTrackOpts{ID: trackId})
|
||||||
|
if err != nil {
|
||||||
|
l.Error().Err(err).Msg("NowPlayingHandler: Failed to get track from database")
|
||||||
|
utils.WriteError(w, "failed to fetch currently playing track from database", http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
|
utils.WriteJSON(w, http.StatusOK, NowPlayingResponse{CurrentlyPlaying: true, Track: *track})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/internal/cfg"
|
||||||
|
"github.com/gabehf/koito/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
DefaultTheme string `json:"default_theme"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCfgHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
utils.WriteJSON(w, http.StatusOK, ServerConfig{DefaultTheme: cfg.DefaultTheme()})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
package images
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/internal/cfg"
|
||||||
|
"github.com/gabehf/koito/internal/logger"
|
||||||
|
"github.com/gabehf/koito/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SubsonicClient struct {
|
||||||
|
url string
|
||||||
|
userAgent string
|
||||||
|
authParams string
|
||||||
|
requestQueue *queue.RequestQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubsonicAlbumResponse struct {
|
||||||
|
SubsonicResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
SearchResult3 struct {
|
||||||
|
Album []struct {
|
||||||
|
CoverArt string `json:"coverArt"`
|
||||||
|
} `json:"album"`
|
||||||
|
} `json:"searchResult3"`
|
||||||
|
} `json:"subsonic-response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubsonicArtistResponse struct {
|
||||||
|
SubsonicResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
SearchResult3 struct {
|
||||||
|
Artist []struct {
|
||||||
|
ArtistImageUrl string `json:"artistImageUrl"`
|
||||||
|
} `json:"artist"`
|
||||||
|
} `json:"searchResult3"`
|
||||||
|
} `json:"subsonic-response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
subsonicAlbumSearchFmtStr = "/rest/search3?%s&f=json&query=%s&v=1.13.0&c=koito&artistCount=0&songCount=0&albumCount=1"
|
||||||
|
subsonicArtistSearchFmtStr = "/rest/search3?%s&f=json&query=%s&v=1.13.0&c=koito&artistCount=1&songCount=0&albumCount=0"
|
||||||
|
subsonicCoverArtFmtStr = "/rest/getCoverArt?%s&id=%s&v=1.13.0&c=koito"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewSubsonicClient() *SubsonicClient {
|
||||||
|
ret := new(SubsonicClient)
|
||||||
|
ret.url = cfg.SubsonicUrl()
|
||||||
|
ret.userAgent = cfg.UserAgent()
|
||||||
|
ret.authParams = cfg.SubsonicParams()
|
||||||
|
ret.requestQueue = queue.NewRequestQueue(5, 5)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SubsonicClient) queue(ctx context.Context, req *http.Request) ([]byte, error) {
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
req.Header.Set("User-Agent", c.userAgent)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resultChan := c.requestQueue.Enqueue(func(client *http.Client, done chan<- queue.RequestResult) {
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
l.Debug().Err(err).Str("url", req.RequestURI).Msg("Failed to contact ImageSrc")
|
||||||
|
done <- queue.RequestResult{Err: err}
|
||||||
|
return
|
||||||
|
} else if resp.StatusCode >= 300 || resp.StatusCode < 200 {
|
||||||
|
err = fmt.Errorf("recieved non-ok status from Subsonic: %s", resp.Status)
|
||||||
|
done <- queue.RequestResult{Body: nil, Err: err}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
done <- queue.RequestResult{Body: body, Err: err}
|
||||||
|
})
|
||||||
|
|
||||||
|
result := <-resultChan
|
||||||
|
return result.Body, result.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SubsonicClient) getEntity(ctx context.Context, endpoint string, result any) error {
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
url := c.url + endpoint
|
||||||
|
l.Debug().Msgf("Sending request to ImageSrc: GET %s", url)
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getEntity: %w", err)
|
||||||
|
}
|
||||||
|
l.Debug().Msg("Adding ImageSrc request to queue")
|
||||||
|
body, err := c.queue(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msg("Subsonic request failed")
|
||||||
|
return fmt.Errorf("getEntity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(body, result)
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msg("Failed to unmarshal Subsonic response")
|
||||||
|
return fmt.Errorf("getEntity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SubsonicClient) GetAlbumImage(ctx context.Context, artist, album string) (string, error) {
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
resp := new(SubsonicAlbumResponse)
|
||||||
|
l.Debug().Msgf("Finding album image for %s from artist %s", album, artist)
|
||||||
|
err := c.getEntity(ctx, fmt.Sprintf(subsonicAlbumSearchFmtStr, c.authParams, url.QueryEscape(artist+" "+album)), resp)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("GetAlbumImage: %v", err)
|
||||||
|
}
|
||||||
|
l.Debug().Any("subsonic_response", resp).Send()
|
||||||
|
if len(resp.SubsonicResponse.SearchResult3.Album) < 1 || resp.SubsonicResponse.SearchResult3.Album[0].CoverArt == "" {
|
||||||
|
return "", fmt.Errorf("GetAlbumImage: failed to get album art")
|
||||||
|
}
|
||||||
|
return cfg.SubsonicUrl() + fmt.Sprintf(subsonicCoverArtFmtStr, c.authParams, url.QueryEscape(resp.SubsonicResponse.SearchResult3.Album[0].CoverArt)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SubsonicClient) GetArtistImage(ctx context.Context, artist string) (string, error) {
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
resp := new(SubsonicArtistResponse)
|
||||||
|
l.Debug().Msgf("Finding artist image for %s", artist)
|
||||||
|
err := c.getEntity(ctx, fmt.Sprintf(subsonicArtistSearchFmtStr, c.authParams, url.QueryEscape(artist)), resp)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("GetArtistImage: %v", err)
|
||||||
|
}
|
||||||
|
l.Debug().Any("subsonic_response", resp).Send()
|
||||||
|
if len(resp.SubsonicResponse.SearchResult3.Artist) < 1 || resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl == "" {
|
||||||
|
return "", fmt.Errorf("GetArtistImage: failed to get artist art")
|
||||||
|
}
|
||||||
|
return resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl, nil
|
||||||
|
}
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
package memkv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type item struct {
|
||||||
|
value interface{}
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type InMemoryStore struct {
|
||||||
|
data map[string]item
|
||||||
|
defaultExpiration time.Duration
|
||||||
|
mu sync.RWMutex
|
||||||
|
stopJanitor chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var Store *InMemoryStore
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Store = NewStore(10 * time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(defaultExpiration time.Duration) *InMemoryStore {
|
||||||
|
s := &InMemoryStore{
|
||||||
|
data: make(map[string]item),
|
||||||
|
defaultExpiration: defaultExpiration,
|
||||||
|
stopJanitor: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.janitor(1 * time.Minute)
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemoryStore) Set(key string, value interface{}, expiration ...time.Duration) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
exp := s.defaultExpiration
|
||||||
|
if len(expiration) > 0 {
|
||||||
|
exp = expiration[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt time.Time
|
||||||
|
if exp > 0 {
|
||||||
|
expiresAt = time.Now().Add(exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.data[key] = item{
|
||||||
|
value: value,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemoryStore) Get(key string) (interface{}, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
it, found := s.data[key]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !it.expiresAt.IsZero() && time.Now().After(it.expiresAt) {
|
||||||
|
s.Delete(key)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return it.value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemoryStore) Delete(key string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.data, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemoryStore) janitor(interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
s.cleanup()
|
||||||
|
case <-s.stopJanitor:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemoryStore) cleanup() {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for k, it := range s.data {
|
||||||
|
if !it.expiresAt.IsZero() && now.After(it.expiresAt) {
|
||||||
|
delete(s.data, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InMemoryStore) Close() {
|
||||||
|
close(s.stopJanitor)
|
||||||
|
}
|
||||||
Loading…
Reference in new issue