mirror of https://github.com/gabehf/Koito.git
Compare commits
2 Commits
bf0ec68cfe
...
6a53fca8f3
| Author | SHA1 | Date |
|---|---|---|
|
|
6a53fca8f3 | 3 weeks ago |
|
|
36f984a1a2 | 3 weeks ago |
@ -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