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 {
|
||||
limit: number,
|
||||
period: string,
|
||||
page: number,
|
||||
artist_id?: number,
|
||||
album_id?: number,
|
||||
track_id?: number
|
||||
limit: number;
|
||||
period: string;
|
||||
page: number;
|
||||
artist_id?: number;
|
||||
album_id?: number;
|
||||
track_id?: number;
|
||||
}
|
||||
interface getActivityArgs {
|
||||
step: string
|
||||
range: number
|
||||
month: number
|
||||
year: number
|
||||
artist_id: number
|
||||
album_id: number
|
||||
track_id: number
|
||||
step: string;
|
||||
range: number;
|
||||
month: number;
|
||||
year: number;
|
||||
artist_id: number;
|
||||
album_id: number;
|
||||
track_id: number;
|
||||
}
|
||||
|
||||
function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> {
|
||||
return fetch(`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Listen>>)
|
||||
function getLastListens(
|
||||
args: getItemsArgs
|
||||
): Promise<PaginatedResponse<Listen>> {
|
||||
return fetch(
|
||||
`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`
|
||||
).then((r) => r.json() as Promise<PaginatedResponse<Listen>>);
|
||||
}
|
||||
|
||||
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> {
|
||||
if (args.artist_id) {
|
||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
||||
} else if (args.album_id) {
|
||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
||||
} else {
|
||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
||||
}
|
||||
if (args.artist_id) {
|
||||
return fetch(
|
||||
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`
|
||||
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
|
||||
} else if (args.album_id) {
|
||||
return fetch(
|
||||
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`
|
||||
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
|
||||
} else {
|
||||
return fetch(
|
||||
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
||||
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
|
||||
}
|
||||
}
|
||||
|
||||
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> {
|
||||
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
||||
if (args.artist_id) {
|
||||
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>)
|
||||
} else {
|
||||
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>)
|
||||
}
|
||||
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
|
||||
if (args.artist_id) {
|
||||
return fetch(baseUri + `&artist_id=${args.artist_id}`).then(
|
||||
(r) => r.json() as Promise<PaginatedResponse<Album>>
|
||||
);
|
||||
} else {
|
||||
return fetch(baseUri).then(
|
||||
(r) => r.json() as Promise<PaginatedResponse<Album>>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
|
||||
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
||||
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Artist>>)
|
||||
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`;
|
||||
return fetch(baseUri).then(
|
||||
(r) => r.json() as Promise<PaginatedResponse<Artist>>
|
||||
);
|
||||
}
|
||||
|
||||
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
|
||||
return fetch(`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`).then(r => r.json() as Promise<ListenActivityItem[]>)
|
||||
return fetch(
|
||||
`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
|
||||
).then((r) => r.json() as Promise<ListenActivityItem[]>);
|
||||
}
|
||||
|
||||
function getStats(period: string): Promise<Stats> {
|
||||
return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>)
|
||||
return fetch(`/apis/web/v1/stats?period=${period}`).then(
|
||||
(r) => r.json() as Promise<Stats>
|
||||
);
|
||||
}
|
||||
|
||||
function search(q: string): Promise<SearchResponse> {
|
||||
q = encodeURIComponent(q)
|
||||
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>)
|
||||
q = encodeURIComponent(q);
|
||||
return fetch(`/apis/web/v1/search?q=${q}`).then(
|
||||
(r) => r.json() as Promise<SearchResponse>
|
||||
);
|
||||
}
|
||||
|
||||
function imageUrl(id: string, size: string) {
|
||||
if (!id) {
|
||||
id = 'default'
|
||||
}
|
||||
return `/images/${size}/${id}`
|
||||
if (!id) {
|
||||
id = "default";
|
||||
}
|
||||
return `/images/${size}/${id}`;
|
||||
}
|
||||
function replaceImage(form: FormData): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/replace-image`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
return fetch(`/apis/web/v1/replace-image`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeTracks(from: number, to: number): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, {
|
||||
method: "POST",
|
||||
})
|
||||
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
function mergeAlbums(
|
||||
from: number,
|
||||
to: number,
|
||||
replaceImage: boolean
|
||||
): Promise<Response> {
|
||||
return fetch(
|
||||
`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
}
|
||||
function mergeArtists(
|
||||
from: number,
|
||||
to: number,
|
||||
replaceImage: boolean
|
||||
): Promise<Response> {
|
||||
return fetch(
|
||||
`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
}
|
||||
function login(
|
||||
username: string,
|
||||
password: string,
|
||||
remember: boolean
|
||||
): Promise<Response> {
|
||||
const form = new URLSearchParams();
|
||||
form.append("username", username);
|
||||
form.append("password", password);
|
||||
form.append("remember_me", String(remember));
|
||||
return fetch(`/apis/web/v1/login`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
function 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 logout(): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/logout`, {
|
||||
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 getCfg(): Promise<Config> {
|
||||
return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise<Config>);
|
||||
}
|
||||
function logout(): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/logout`, {
|
||||
method: "POST",
|
||||
})
|
||||
|
||||
function submitListen(id: string, ts: Date): Promise<Response> {
|
||||
const form = new URLSearchParams();
|
||||
form.append("track_id", id);
|
||||
const ms = new Date(ts).getTime();
|
||||
const unix = Math.floor(ms / 1000);
|
||||
form.append("unix", unix.toString());
|
||||
return fetch(`/apis/web/v1/listen`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
function getApiKeys(): Promise<ApiKey[]> {
|
||||
return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>)
|
||||
return fetch(`/apis/web/v1/user/apikeys`).then(
|
||||
(r) => r.json() as Promise<ApiKey[]>
|
||||
);
|
||||
}
|
||||
const createApiKey = async (label: string): Promise<ApiKey> => {
|
||||
const form = new URLSearchParams
|
||||
form.append('label', label)
|
||||
const r = await fetch(`/apis/web/v1/user/apikeys`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!r.ok) {
|
||||
let errorMessage = `error: ${r.status}`;
|
||||
try {
|
||||
const errorData: ApiError = await r.json();
|
||||
if (errorData && typeof errorData.error === 'string') {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("unexpected api error:", e);
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
const form = new URLSearchParams();
|
||||
form.append("label", label);
|
||||
const r = await fetch(`/apis/web/v1/user/apikeys`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!r.ok) {
|
||||
let errorMessage = `error: ${r.status}`;
|
||||
try {
|
||||
const errorData: ApiError = await r.json();
|
||||
if (errorData && typeof errorData.error === "string") {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("unexpected api error:", e);
|
||||
}
|
||||
const data: ApiKey = await r.json();
|
||||
return data;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
const data: ApiKey = await r.json();
|
||||
return data;
|
||||
};
|
||||
function deleteApiKey(id: number): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
function updateApiKeyLabel(id: number, label: string): Promise<Response> {
|
||||
const form = new URLSearchParams
|
||||
form.append('id', String(id))
|
||||
form.append('label', label)
|
||||
return fetch(`/apis/web/v1/user/apikeys`, {
|
||||
method: "PATCH",
|
||||
body: form,
|
||||
})
|
||||
const form = new URLSearchParams();
|
||||
form.append("id", String(id));
|
||||
form.append("label", label);
|
||||
return fetch(`/apis/web/v1/user/apikeys`, {
|
||||
method: "PATCH",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
function deleteItem(itemType: string, id: number): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
function updateUser(username: string, password: string) {
|
||||
const form = new URLSearchParams
|
||||
form.append('username', username)
|
||||
form.append('password', password)
|
||||
return fetch(`/apis/web/v1/user`, {
|
||||
method: "PATCH",
|
||||
body: form,
|
||||
})
|
||||
const form = new URLSearchParams();
|
||||
form.append("username", username);
|
||||
form.append("password", password);
|
||||
return fetch(`/apis/web/v1/user`, {
|
||||
method: "PATCH",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
function getAliases(type: string, id: number): Promise<Alias[]> {
|
||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise<Alias[]>)
|
||||
}
|
||||
function createAlias(type: string, id: number, alias: string): Promise<Response> {
|
||||
const form = new URLSearchParams
|
||||
form.append(`${type}_id`, String(id))
|
||||
form.append('alias', alias)
|
||||
return fetch(`/apis/web/v1/aliases`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
function deleteAlias(type: string, id: number, alias: string): Promise<Response> {
|
||||
const form = new URLSearchParams
|
||||
form.append(`${type}_id`, String(id))
|
||||
form.append('alias', alias)
|
||||
return fetch(`/apis/web/v1/aliases/delete`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
function setPrimaryAlias(type: string, id: number, alias: string): Promise<Response> {
|
||||
const form = new URLSearchParams
|
||||
form.append(`${type}_id`, String(id))
|
||||
form.append('alias', alias)
|
||||
return fetch(`/apis/web/v1/aliases/primary`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(
|
||||
(r) => r.json() as Promise<Alias[]>
|
||||
);
|
||||
}
|
||||
function createAlias(
|
||||
type: string,
|
||||
id: number,
|
||||
alias: string
|
||||
): Promise<Response> {
|
||||
const form = new URLSearchParams();
|
||||
form.append(`${type}_id`, String(id));
|
||||
form.append("alias", alias);
|
||||
return fetch(`/apis/web/v1/aliases`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
function deleteAlias(
|
||||
type: string,
|
||||
id: number,
|
||||
alias: string
|
||||
): Promise<Response> {
|
||||
const form = new URLSearchParams();
|
||||
form.append(`${type}_id`, String(id));
|
||||
form.append("alias", alias);
|
||||
return fetch(`/apis/web/v1/aliases/delete`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
function setPrimaryAlias(
|
||||
type: string,
|
||||
id: number,
|
||||
alias: string
|
||||
): Promise<Response> {
|
||||
const form = new URLSearchParams();
|
||||
form.append(`${type}_id`, String(id));
|
||||
form.append("alias", alias);
|
||||
return fetch(`/apis/web/v1/aliases/primary`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
function getAlbum(id: number): Promise<Album> {
|
||||
return fetch(`/apis/web/v1/album?id=${id}`).then(r => r.json() as Promise<Album>)
|
||||
return fetch(`/apis/web/v1/album?id=${id}`).then(
|
||||
(r) => r.json() as Promise<Album>
|
||||
);
|
||||
}
|
||||
|
||||
function deleteListen(listen: Listen): Promise<Response> {
|
||||
const ms = new Date(listen.time).getTime()
|
||||
const unix= Math.floor(ms / 1000);
|
||||
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
const ms = new Date(listen.time).getTime();
|
||||
const unix = Math.floor(ms / 1000);
|
||||
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
function getExport() {
|
||||
function getExport() {}
|
||||
|
||||
function getNowPlaying(): Promise<NowPlaying> {
|
||||
return fetch("/apis/web/v1/now-playing").then((r) => r.json());
|
||||
}
|
||||
|
||||
export {
|
||||
getLastListens,
|
||||
getTopTracks,
|
||||
getTopAlbums,
|
||||
getTopArtists,
|
||||
getActivity,
|
||||
getStats,
|
||||
search,
|
||||
replaceImage,
|
||||
mergeTracks,
|
||||
mergeAlbums,
|
||||
mergeArtists,
|
||||
imageUrl,
|
||||
login,
|
||||
logout,
|
||||
deleteItem,
|
||||
updateUser,
|
||||
getAliases,
|
||||
createAlias,
|
||||
deleteAlias,
|
||||
setPrimaryAlias,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
updateApiKeyLabel,
|
||||
deleteListen,
|
||||
getAlbum,
|
||||
getExport,
|
||||
}
|
||||
getLastListens,
|
||||
getTopTracks,
|
||||
getTopAlbums,
|
||||
getTopArtists,
|
||||
getActivity,
|
||||
getStats,
|
||||
search,
|
||||
replaceImage,
|
||||
mergeTracks,
|
||||
mergeAlbums,
|
||||
mergeArtists,
|
||||
imageUrl,
|
||||
login,
|
||||
logout,
|
||||
getCfg,
|
||||
deleteItem,
|
||||
updateUser,
|
||||
getAliases,
|
||||
createAlias,
|
||||
deleteAlias,
|
||||
setPrimaryAlias,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
updateApiKeyLabel,
|
||||
deleteListen,
|
||||
getAlbum,
|
||||
getExport,
|
||||
submitListen,
|
||||
getNowPlaying,
|
||||
};
|
||||
type Track = {
|
||||
id: number
|
||||
title: string
|
||||
artists: SimpleArtists[]
|
||||
listen_count: number
|
||||
image: string
|
||||
album_id: number
|
||||
musicbrainz_id: string
|
||||
time_listened: number
|
||||
}
|
||||
id: number;
|
||||
title: string;
|
||||
artists: SimpleArtists[];
|
||||
listen_count: number;
|
||||
image: string;
|
||||
album_id: number;
|
||||
musicbrainz_id: string;
|
||||
time_listened: number;
|
||||
first_listen: number;
|
||||
};
|
||||
type Artist = {
|
||||
id: number
|
||||
name: string
|
||||
image: string,
|
||||
aliases: string[]
|
||||
listen_count: number
|
||||
musicbrainz_id: string
|
||||
time_listened: number
|
||||
is_primary: boolean
|
||||
}
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
aliases: string[];
|
||||
listen_count: number;
|
||||
musicbrainz_id: string;
|
||||
time_listened: number;
|
||||
first_listen: number;
|
||||
is_primary: boolean;
|
||||
};
|
||||
type Album = {
|
||||
id: number,
|
||||
title: string
|
||||
image: string
|
||||
listen_count: number
|
||||
is_various_artists: boolean
|
||||
artists: SimpleArtists[]
|
||||
musicbrainz_id: string
|
||||
time_listened: number
|
||||
}
|
||||
id: number;
|
||||
title: string;
|
||||
image: string;
|
||||
listen_count: number;
|
||||
is_various_artists: boolean;
|
||||
artists: SimpleArtists[];
|
||||
musicbrainz_id: string;
|
||||
time_listened: number;
|
||||
first_listen: number;
|
||||
};
|
||||
type Alias = {
|
||||
id: number
|
||||
alias: string
|
||||
source: string
|
||||
is_primary: boolean
|
||||
}
|
||||
id: number;
|
||||
alias: string;
|
||||
source: string;
|
||||
is_primary: boolean;
|
||||
};
|
||||
type Listen = {
|
||||
time: string,
|
||||
track: Track,
|
||||
}
|
||||
time: string;
|
||||
track: Track;
|
||||
};
|
||||
type PaginatedResponse<T> = {
|
||||
items: T[],
|
||||
total_record_count: number,
|
||||
has_next_page: boolean,
|
||||
current_page: number,
|
||||
items_per_page: number,
|
||||
}
|
||||
items: T[];
|
||||
total_record_count: number;
|
||||
has_next_page: boolean;
|
||||
current_page: number;
|
||||
items_per_page: number;
|
||||
};
|
||||
type ListenActivityItem = {
|
||||
start_time: Date,
|
||||
listens: number
|
||||
}
|
||||
start_time: Date;
|
||||
listens: number;
|
||||
};
|
||||
type SimpleArtists = {
|
||||
name: string
|
||||
id: number
|
||||
}
|
||||
name: string;
|
||||
id: number;
|
||||
};
|
||||
type Stats = {
|
||||
listen_count: number
|
||||
track_count: number
|
||||
album_count: number
|
||||
artist_count: number
|
||||
minutes_listened: number
|
||||
}
|
||||
listen_count: number;
|
||||
track_count: number;
|
||||
album_count: number;
|
||||
artist_count: number;
|
||||
minutes_listened: number;
|
||||
};
|
||||
type SearchResponse = {
|
||||
albums: Album[]
|
||||
artists: Artist[]
|
||||
tracks: Track[]
|
||||
}
|
||||
albums: Album[];
|
||||
artists: Artist[];
|
||||
tracks: Track[];
|
||||
};
|
||||
type User = {
|
||||
id: number
|
||||
username: string
|
||||
role: 'user' | 'admin'
|
||||
}
|
||||
id: number;
|
||||
username: string;
|
||||
role: "user" | "admin";
|
||||
};
|
||||
type ApiKey = {
|
||||
id: number
|
||||
key: string
|
||||
label: string
|
||||
created_at: Date
|
||||
}
|
||||
id: number;
|
||||
key: string;
|
||||
label: string;
|
||||
created_at: Date;
|
||||
};
|
||||
type ApiError = {
|
||||
error: string
|
||||
}
|
||||
error: string;
|
||||
};
|
||||
type Config = {
|
||||
default_theme: string;
|
||||
};
|
||||
type NowPlaying = {
|
||||
currently_playing: boolean;
|
||||
track: Track;
|
||||
};
|
||||
|
||||
export type {
|
||||
getItemsArgs,
|
||||
getActivityArgs,
|
||||
Track,
|
||||
Artist,
|
||||
Album,
|
||||
Listen,
|
||||
SearchResponse,
|
||||
PaginatedResponse,
|
||||
ListenActivityItem,
|
||||
User,
|
||||
Alias,
|
||||
ApiKey,
|
||||
ApiError
|
||||
}
|
||||
getItemsArgs,
|
||||
getActivityArgs,
|
||||
Track,
|
||||
Artist,
|
||||
Album,
|
||||
Listen,
|
||||
SearchResponse,
|
||||
PaginatedResponse,
|
||||
ListenActivityItem,
|
||||
User,
|
||||
Alias,
|
||||
ApiKey,
|
||||
ApiError,
|
||||
Config,
|
||||
NowPlaying,
|
||||
};
|
||||
|
||||
@ -1,200 +1,197 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api"
|
||||
import Popup from "./Popup"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTheme } from "~/hooks/useTheme"
|
||||
import ActivityOptsSelector from "./ActivityOptsSelector"
|
||||
|
||||
function getPrimaryColor(): string {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--color-primary')
|
||||
.trim();
|
||||
|
||||
const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
|
||||
if (rgbMatch) {
|
||||
const [, r, g, b] = rgbMatch.map(Number);
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b]
|
||||
.map((n) => n.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getActivity,
|
||||
type getActivityArgs,
|
||||
type ListenActivityItem,
|
||||
} from "api/api";
|
||||
import Popup from "./Popup";
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "~/hooks/useTheme";
|
||||
import ActivityOptsSelector from "./ActivityOptsSelector";
|
||||
import type { Theme } from "~/styles/themes.css";
|
||||
|
||||
function getPrimaryColor(theme: Theme): string {
|
||||
const value = theme.primary;
|
||||
const rgbMatch = value.match(
|
||||
/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/
|
||||
);
|
||||
if (rgbMatch) {
|
||||
const [, r, g, b] = rgbMatch.map(Number);
|
||||
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
step?: string
|
||||
range?: number
|
||||
month?: number
|
||||
year?: number
|
||||
artistId?: number
|
||||
albumId?: number
|
||||
trackId?: number
|
||||
configurable?: boolean
|
||||
autoAdjust?: boolean
|
||||
step?: string;
|
||||
range?: number;
|
||||
month?: number;
|
||||
year?: number;
|
||||
artistId?: number;
|
||||
albumId?: number;
|
||||
trackId?: number;
|
||||
configurable?: boolean;
|
||||
autoAdjust?: boolean;
|
||||
}
|
||||
|
||||
export default function ActivityGrid({
|
||||
step = 'day',
|
||||
range = 182,
|
||||
month = 0,
|
||||
year = 0,
|
||||
artistId = 0,
|
||||
albumId = 0,
|
||||
trackId = 0,
|
||||
configurable = false,
|
||||
}: Props) {
|
||||
|
||||
const [color, setColor] = useState(getPrimaryColor())
|
||||
const [stepState, setStep] = useState(step)
|
||||
const [rangeState, setRange] = useState(range)
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'listen-activity',
|
||||
{
|
||||
step: stepState,
|
||||
range: rangeState,
|
||||
month: month,
|
||||
year: year,
|
||||
artist_id: artistId,
|
||||
album_id: albumId,
|
||||
track_id: trackId
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
||||
});
|
||||
|
||||
|
||||
const { theme } = useTheme();
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const color = getPrimaryColor()
|
||||
setColor(color);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [theme]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[500px]">
|
||||
<h2>Activity</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
step = "day",
|
||||
range = 182,
|
||||
month = 0,
|
||||
year = 0,
|
||||
artistId = 0,
|
||||
albumId = 0,
|
||||
trackId = 0,
|
||||
configurable = false,
|
||||
}: Props) {
|
||||
const [stepState, setStep] = useState(step);
|
||||
const [rangeState, setRange] = useState(range);
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"listen-activity",
|
||||
{
|
||||
step: stepState,
|
||||
range: rangeState,
|
||||
month: month,
|
||||
year: year,
|
||||
artist_id: artistId,
|
||||
album_id: albumId,
|
||||
track_id: trackId,
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
||||
});
|
||||
|
||||
const { theme, themeName } = useTheme();
|
||||
const color = getPrimaryColor(theme);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[500px]">
|
||||
<h2>Activity</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isError) return <p className="error">Error:{error.message}</p>;
|
||||
|
||||
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/
|
||||
function LightenDarkenColor(hex: string, lum: number) {
|
||||
// validate hex string
|
||||
hex = String(hex).replace(/[^0-9a-f]/gi, "");
|
||||
if (hex.length < 6) {
|
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||
}
|
||||
if (isError) return <p className="error">Error:{error.message}</p>
|
||||
|
||||
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/
|
||||
function LightenDarkenColor(hex: string, lum: number) {
|
||||
// validate hex string
|
||||
hex = String(hex).replace(/[^0-9a-f]/gi, '');
|
||||
if (hex.length < 6) {
|
||||
hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
||||
}
|
||||
lum = lum || 0;
|
||||
|
||||
// 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;
|
||||
lum = lum || 0;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
const getDarkenAmount = (v: number, t: number): number => {
|
||||
|
||||
// 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 adjustment = artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1
|
||||
|
||||
// automatically adjust the target value based on step
|
||||
// the smartest way to do this would be to have the api return the
|
||||
// highest value in the range. too bad im not smart
|
||||
switch (stepState) {
|
||||
case 'day':
|
||||
t = 10 * adjustment
|
||||
break;
|
||||
case 'week':
|
||||
t = 20 * adjustment
|
||||
break;
|
||||
case 'month':
|
||||
t = 50 * adjustment
|
||||
break;
|
||||
case 'year':
|
||||
t = 100 * adjustment
|
||||
break;
|
||||
}
|
||||
|
||||
v = Math.min(v, t)
|
||||
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
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
|
||||
const getDarkenAmount = (v: number, t: number): number => {
|
||||
// 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 adjustment =
|
||||
artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1;
|
||||
|
||||
// automatically adjust the target value based on step
|
||||
// the smartest way to do this would be to have the api return the
|
||||
// highest value in the range. too bad im not smart
|
||||
switch (stepState) {
|
||||
case "day":
|
||||
t = 10 * adjustment;
|
||||
break;
|
||||
case "week":
|
||||
t = 20 * adjustment;
|
||||
break;
|
||||
case "month":
|
||||
t = 50 * adjustment;
|
||||
break;
|
||||
case "year":
|
||||
t = 100 * adjustment;
|
||||
break;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 26 * 7;
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
|
||||
chunks.push(data.slice(i, i + CHUNK_SIZE));
|
||||
v = Math.min(v, t);
|
||||
if (themeName === "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) * 0.8;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start">
|
||||
<h2>Activity</h2>
|
||||
{configurable ? (
|
||||
<ActivityOptsSelector
|
||||
rangeSetter={setRange}
|
||||
currentRange={rangeState}
|
||||
stepSetter={setStep}
|
||||
currentStep={stepState}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{chunks.map((chunk, index) => (
|
||||
};
|
||||
|
||||
const CHUNK_SIZE = 26 * 7;
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
|
||||
chunks.push(data.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start">
|
||||
<h2>Activity</h2>
|
||||
{configurable ? (
|
||||
<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
|
||||
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
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background:
|
||||
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>
|
||||
))}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
background:
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 { useEffect, useState } from 'react';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import themes from '~/styles/themes.css';
|
||||
import ThemeOption from './ThemeOption';
|
||||
import { AsyncButton } from '../AsyncButton';
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import themes from "~/styles/themes.css";
|
||||
import ThemeOption from "./ThemeOption";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const initialTheme = {
|
||||
bg: "#1e1816",
|
||||
bgSecondary: "#2f2623",
|
||||
bgTertiary: "#453733",
|
||||
fg: "#f8f3ec",
|
||||
fgSecondary: "#d6ccc2",
|
||||
fgTertiary: "#b4a89c",
|
||||
primary: "#f5a97f",
|
||||
primaryDim: "#d88b65",
|
||||
accent: "#f9db6d",
|
||||
accentDim: "#d9bc55",
|
||||
error: "#e26c6a",
|
||||
warning: "#f5b851",
|
||||
success: "#8fc48f",
|
||||
info: "#87b8dd",
|
||||
}
|
||||
|
||||
const { setCustomTheme, getCustomTheme } = useTheme()
|
||||
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " "))
|
||||
|
||||
const handleCustomTheme = () => {
|
||||
console.log(custom)
|
||||
try {
|
||||
const theme = JSON.parse(custom)
|
||||
theme.name = "custom"
|
||||
setCustomTheme(theme)
|
||||
delete theme.name
|
||||
setCustom(JSON.stringify(theme, null, " "))
|
||||
console.log(theme)
|
||||
} catch(err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
const { setTheme } = useTheme();
|
||||
const initialTheme = {
|
||||
bg: "#1e1816",
|
||||
bgSecondary: "#2f2623",
|
||||
bgTertiary: "#453733",
|
||||
fg: "#f8f3ec",
|
||||
fgSecondary: "#d6ccc2",
|
||||
fgTertiary: "#b4a89c",
|
||||
primary: "#f5a97f",
|
||||
primaryDim: "#d88b65",
|
||||
accent: "#f9db6d",
|
||||
accentDim: "#d9bc55",
|
||||
error: "#e26c6a",
|
||||
warning: "#f5b851",
|
||||
success: "#8fc48f",
|
||||
info: "#87b8dd",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
setTheme(theme)
|
||||
}
|
||||
}, [theme]);
|
||||
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme();
|
||||
const [custom, setCustom] = useState(
|
||||
JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-10'>
|
||||
<div>
|
||||
<h2>Select Theme</h2>
|
||||
<div className="grid grid-cols-2 items-center gap-2">
|
||||
{themes.map((t) => (
|
||||
<ThemeOption setTheme={setTheme} key={t.name} theme={t} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Use Custom Theme</h2>
|
||||
<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>
|
||||
const handleCustomTheme = () => {
|
||||
console.log(custom);
|
||||
try {
|
||||
const themeData = JSON.parse(custom);
|
||||
setCustomTheme(themeData);
|
||||
setCustom(JSON.stringify(themeData, null, " "));
|
||||
console.log(themeData);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2>Select Theme</h2>
|
||||
<div className="mb-3">
|
||||
<AsyncButton onClick={resetTheme}>Reset</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 items-center gap-2">
|
||||
{Object.entries(themes).map(([name, themeData]) => (
|
||||
<ThemeOption
|
||||
setTheme={setTheme}
|
||||
key={name}
|
||||
theme={themeData}
|
||||
themeName={name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Use Custom Theme</h2>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,95 +1,131 @@
|
||||
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react';
|
||||
import { type Theme } from '~/styles/themes.css';
|
||||
import { themeVars } from '~/styles/vars.css';
|
||||
import {
|
||||
createContext,
|
||||
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 {
|
||||
theme: string;
|
||||
setTheme: (theme: string) => void;
|
||||
setCustomTheme: (theme: Theme) => void;
|
||||
getCustomTheme: () => Theme | undefined;
|
||||
themeName: string;
|
||||
theme: Theme;
|
||||
setTheme: (theme: string) => void;
|
||||
resetTheme: () => void;
|
||||
setCustomTheme: (theme: Theme) => void;
|
||||
getCustomTheme: () => Theme | undefined;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
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) {
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(theme)) {
|
||||
if (key === 'name') continue;
|
||||
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
||||
}
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(theme)) {
|
||||
if (key === "name") continue;
|
||||
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
||||
}
|
||||
}
|
||||
|
||||
function clearCustomThemeVars() {
|
||||
for (const cssVar of Object.values(themeVars)) {
|
||||
document.documentElement.style.removeProperty(cssVar);
|
||||
}
|
||||
for (const cssVar of Object.values(themeVars)) {
|
||||
document.documentElement.style.removeProperty(cssVar);
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
theme: initialTheme,
|
||||
children,
|
||||
}: {
|
||||
theme: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [theme, setThemeName] = useState(initialTheme);
|
||||
|
||||
const setTheme = (theme: string) => {
|
||||
setThemeName(theme)
|
||||
function getStoredCustomTheme(): Theme | undefined {
|
||||
const themeStr = localStorage.getItem("custom-theme");
|
||||
if (!themeStr) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(themeStr);
|
||||
const { name, ...theme } = parsed;
|
||||
return theme as Theme;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
localStorage.setItem('custom-theme', JSON.stringify(customTheme));
|
||||
applyCustomThemeVars(customTheme);
|
||||
setTheme('custom');
|
||||
}, []);
|
||||
|
||||
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
|
||||
}
|
||||
if (themeName === "custom") {
|
||||
applyCustomThemeVars(currentTheme);
|
||||
} else {
|
||||
clearCustomThemeVars();
|
||||
}
|
||||
}, [themeName, currentTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
root.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme)
|
||||
console.log(theme)
|
||||
|
||||
if (theme === 'custom') {
|
||||
const saved = localStorage.getItem('custom-theme');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved) as Theme;
|
||||
applyCustomThemeVars(parsed);
|
||||
} 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>
|
||||
);
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
themeName,
|
||||
theme: currentTheme,
|
||||
setTheme,
|
||||
resetTheme,
|
||||
setCustomTheme,
|
||||
getCustomTheme,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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