Compare commits

..

No commits in common. '0186cefb5253494e9b6ddab85152eff811fe0d5f' and 'd0c4d078d57bdce5cf98b8a321a47087c1883800' have entirely different histories.

@ -1,419 +1,353 @@
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( function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> {
args: getItemsArgs 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>>)
): 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( 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>>)
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}` } else if (args.album_id) {
).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 if (args.album_id) { } else {
return fetch( return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
`/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( return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>)
(r) => r.json() as Promise<PaginatedResponse<Album>> } else {
); 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( return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Artist>>)
(r) => r.json() as Promise<PaginatedResponse<Artist>>
);
} }
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> { function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
return fetch( 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[]>)
`/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( return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>)
(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( return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>)
(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( function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise<Response> {
from: number, return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, {
to: number, method: "POST",
replaceImage: boolean })
): Promise<Response> { }
return fetch( function mergeArtists(from: number, to: number, replaceImage: boolean): Promise<Response> {
`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, {
{ method: "POST",
method: "POST", })
} }
); function login(username: string, password: string, remember: boolean): Promise<Response> {
} const form = new URLSearchParams
function mergeArtists( form.append('username', username)
from: number, form.append('password', password)
to: number, form.append('remember_me', String(remember))
replaceImage: boolean return fetch(`/apis/web/v1/login`, {
): Promise<Response> { method: "POST",
return fetch( body: form,
`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, })
{
method: "POST",
}
);
}
function login(
username: string,
password: string,
remember: boolean
): Promise<Response> {
const form = new URLSearchParams();
form.append("username", username);
form.append("password", password);
form.append("remember_me", String(remember));
return fetch(`/apis/web/v1/login`, {
method: "POST",
body: form,
});
} }
function logout(): Promise<Response> { function logout(): Promise<Response> {
return fetch(`/apis/web/v1/logout`, { return fetch(`/apis/web/v1/logout`, {
method: "POST", method: "POST",
}); })
} }
function getCfg(): Promise<Config> { function getCfg(): Promise<Config> {
return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise<Config>); return fetch(`/apis/web/v1/config`).then(r => r.json() as Promise<Config>)
} }
function submitListen(id: string, ts: Date): Promise<Response> { function submitListen(id: string, ts: Date): Promise<Response> {
const form = new URLSearchParams(); const form = new URLSearchParams
form.append("track_id", id); form.append("track_id", id)
const ms = new Date(ts).getTime(); const ms = new Date(ts).getTime()
const unix = Math.floor(ms / 1000); const unix= Math.floor(ms / 1000);
form.append("unix", unix.toString()); form.append("unix", unix.toString())
return fetch(`/apis/web/v1/listen`, { return fetch(`/apis/web/v1/listen`, {
method: "POST", method: "POST",
body: form, body: form,
}); })
} }
function getApiKeys(): Promise<ApiKey[]> { function getApiKeys(): Promise<ApiKey[]> {
return fetch(`/apis/web/v1/user/apikeys`).then( return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>)
(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);
} }
throw new Error(errorMessage); const data: ApiKey = await r.json();
} 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( return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise<Alias[]>)
(r) => r.json() as Promise<Alias[]> }
); function createAlias(type: string, id: number, alias: string): Promise<Response> {
} const form = new URLSearchParams
function createAlias( form.append(`${type}_id`, String(id))
type: string, form.append('alias', alias)
id: number, return fetch(`/apis/web/v1/aliases`, {
alias: string method: 'POST',
): Promise<Response> { body: form,
const form = new URLSearchParams(); })
form.append(`${type}_id`, String(id)); }
form.append("alias", alias); function deleteAlias(type: string, id: number, alias: string): Promise<Response> {
return fetch(`/apis/web/v1/aliases`, { const form = new URLSearchParams
method: "POST", form.append(`${type}_id`, String(id))
body: form, form.append('alias', alias)
}); return fetch(`/apis/web/v1/aliases/delete`, {
} method: "POST",
function deleteAlias( body: form,
type: string, })
id: number, }
alias: string function setPrimaryAlias(type: string, id: number, alias: string): Promise<Response> {
): Promise<Response> { const form = new URLSearchParams
const form = new URLSearchParams(); form.append(`${type}_id`, String(id))
form.append(`${type}_id`, String(id)); form.append('alias', alias)
form.append("alias", alias); return fetch(`/apis/web/v1/aliases/primary`, {
return fetch(`/apis/web/v1/aliases/delete`, { method: "POST",
method: "POST", body: form,
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( return fetch(`/apis/web/v1/album?id=${id}`).then(r => r.json() as Promise<Album>)
(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,
getCfg, getCfg,
deleteItem, deleteItem,
updateUser, updateUser,
getAliases, getAliases,
createAlias, createAlias,
deleteAlias, deleteAlias,
setPrimaryAlias, setPrimaryAlias,
getApiKeys, getApiKeys,
createApiKey, createApiKey,
deleteApiKey, deleteApiKey,
updateApiKeyLabel, updateApiKeyLabel,
deleteListen, deleteListen,
getAlbum, getAlbum,
getExport, getExport,
submitListen, 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; 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
first_listen: number; first_listen: number
is_primary: boolean; 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; 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 = { type Config = {
default_theme: string; 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, Config
NowPlaying, }
};

@ -1,197 +1,191 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query"
import { import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api"
getActivity, import Popup from "./Popup"
type getActivityArgs, import { useState } from "react"
type ListenActivityItem, import { useTheme } from "~/hooks/useTheme"
} from "api/api"; import ActivityOptsSelector from "./ActivityOptsSelector"
import Popup from "./Popup"; import type { Theme } from "~/styles/themes.css"
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 { function getPrimaryColor(theme: Theme): string {
const value = theme.primary; const value = theme.primary;
const rgbMatch = value.match( const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
/^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);
if (rgbMatch) { return (
const [, r, g, b] = rgbMatch.map(Number); '#' +
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join(""); [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 [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, themeName } = useTheme();
if (isPending) { const color = getPrimaryColor(theme);
return (
<div className="w-[500px]">
<h2>Activity</h2> if (isPending) {
<p>Loading...</p> return (
</div> <div className="w-[500px]">
); <h2>Activity</h2>
} <p>Loading...</p>
if (isError) return <p className="error">Error:{error.message}</p>; </div>
)
// 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; if (isError) return <p className="error">Error:{error.message}</p>
// convert to decimal and change luminosity // from https://css-tricks.com/snippets/javascript/lighten-darken-color/
var rgb = "#", function LightenDarkenColor(hex: string, lum: number) {
c, // validate hex string
i; hex = String(hex).replace(/[^0-9a-f]/gi, '');
for (i = 0; i < 3; i++) { if (hex.length < 6) {
c = parseInt(hex.substring(i * 2, i * 2 + 2), 16); hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
c = Math.round(Math.min(Math.max(0, c + c * lum), 255)).toString(16); }
rgb += ("00" + c).substring(c.length); 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;
} }
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.
const getDarkenAmount = (v: number, t: number): number => { // is it jsut better to just pass the target in as a var? probably.
// really ugly way to just check if this is for all items and not a specific item. const adjustment = artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1
// is it jsut better to just pass the target in as a var? probably.
const adjustment = // automatically adjust the target value based on step
artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1; // the smartest way to do this would be to have the api return the
// highest value in the range. too bad im not smart
// automatically adjust the target value based on step switch (stepState) {
// the smartest way to do this would be to have the api return the case 'day':
// highest value in the range. too bad im not smart t = 10 * adjustment
switch (stepState) { break;
case "day": case 'week':
t = 10 * adjustment; t = 20 * adjustment
break; break;
case "week": case 'month':
t = 20 * adjustment; t = 50 * adjustment
break; break;
case "month": case 'year':
t = 50 * adjustment; t = 100 * adjustment
break; break;
case "year": }
t = 100 * adjustment;
break; 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) * .8
}
} }
v = Math.min(v, t); const CHUNK_SIZE = 26 * 7;
if (themeName === "pearl") { const chunks = [];
// special case for the only light theme lol
// could be generalized by pragmatically comparing the for (let i = 0; i < data.length; i += CHUNK_SIZE) {
// lightness of the bg vs the primary but eh chunks.push(data.slice(i, i + CHUNK_SIZE));
return (t - v) / t;
} else {
return ((v - t) / t) * 0.8;
} }
};
return (
const CHUNK_SIZE = 26 * 7; <div className="flex flex-col items-start">
const chunks = []; <h2>Activity</h2>
{configurable ? (
for (let i = 0; i < data.length; i += CHUNK_SIZE) { <ActivityOptsSelector
chunks.push(data.slice(i, i + CHUNK_SIZE)); rangeSetter={setRange}
} currentRange={rangeState}
stepSetter={setStep}
return ( currentStep={stepState}
<div className="flex flex-col items-start"> />
<h2>Activity</h2> ) : null}
{configurable ? (
<ActivityOptsSelector {chunks.map((chunk, index) => (
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
style={{ key={index}
display: "inline-block", className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px] mb-4"
background: >
item.listens > 0 {chunk.map((item) => (
? LightenDarkenColor( <div
color, key={new Date(item.start_time).toString()}
getDarkenAmount(item.listens, 100) className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
) >
: "var(--color-bg-secondary)", <Popup
}} position="top"
className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${ space={12}
item.listens > 0 extraClasses="left-2"
? "" inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
: "border-[0.5px] border-(--color-bg-tertiary)" >
}`} <div
></div> style={{
</Popup> display: 'inline-block',
</div> 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> </div>
))} );
</div> }
);
}

@ -1,150 +1,147 @@
import { useState } from "react"; import { useEffect, useState } from "react"
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query"
import { timeSince } from "~/utils/utils"; import { timeSince } from "~/utils/utils"
import ArtistLinks from "./ArtistLinks"; import ArtistLinks from "./ArtistLinks"
import { import { deleteListen, getLastListens, type getItemsArgs, type Listen, type Track } from "api/api"
deleteListen, import { Link } from "react-router"
getLastListens, import { useAppContext } from "~/providers/AppProvider"
getNowPlaying,
type getItemsArgs,
type Listen,
type Track,
} from "api/api";
import { Link } from "react-router";
import { useAppContext } from "~/providers/AppProvider";
interface Props { interface Props {
limit: number; limit: number
artistId?: Number; artistId?: Number
albumId?: Number; albumId?: Number
trackId?: number; trackId?: number
hideArtists?: boolean; hideArtists?: boolean
showNowPlaying?: boolean; showNowPlaying?: boolean
} }
export default function LastPlays(props: Props) { export default function LastPlays(props: Props) {
const { user } = useAppContext(); const { user } = useAppContext()
const { isPending, isError, data, error } = useQuery({ const { isPending, isError, data, error } = useQuery({
queryKey: [ queryKey: ['last-listens', {
"last-listens", limit: props.limit,
{ period: 'all_time',
limit: props.limit, artist_id: props.artistId,
period: "all_time", album_id: props.albumId,
artist_id: props.artistId, track_id: props.trackId
album_id: props.albumId, }],
track_id: props.trackId, queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
}, })
],
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
});
const { data: npData } = useQuery({
queryKey: ["now-playing"],
queryFn: () => getNowPlaying(),
});
const [items, setItems] = useState<Listen[] | null>(null);
const handleDelete = async (listen: Listen) => { const [items, setItems] = useState<Listen[] | null>(null)
if (!data) return; const [nowPlaying, setNowPlaying] = useState<Track | undefined>(undefined)
try {
const res = await deleteListen(listen); useEffect(() => {
if (res.ok || (res.status >= 200 && res.status < 300)) { fetch('/apis/web/v1/now-playing')
setItems((prev) => .then(r => r.json())
(prev ?? data.items).filter((i) => i.time !== listen.time) .then(r => {
); console.log(r)
} else { if (r.currently_playing) {
console.error("Failed to delete listen:", res.status); setNowPlaying(r.track)
} }
} catch (err) { })
console.error("Error deleting listen:", err); }, [])
const handleDelete = async (listen: Listen) => {
if (!data) return
try {
const res = await deleteListen(listen)
if (res.ok || (res.status >= 200 && res.status < 300)) {
setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time))
} else {
console.error("Failed to delete listen:", res.status)
}
} catch (err) {
console.error("Error deleting listen:", err)
}
} }
};
if (isPending) { if (isPending) {
return ( return (
<div className="w-[300px] sm:w-[500px]"> <div className="w-[300px] sm:w-[500px]">
<h2>Last Played</h2> <h2>Last Played</h2>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); )
} }
if (isError) { if (isError) {
return <p className="error">Error: {error.message}</p>; return <p className="error">Error: {error.message}</p>
} }
const listens = items ?? data.items; const listens = items ?? data.items
let params = ""; let params = ''
params += props.artistId ? `&artist_id=${props.artistId}` : ""; params += props.artistId ? `&artist_id=${props.artistId}` : ''
params += props.albumId ? `&album_id=${props.albumId}` : ""; params += props.albumId ? `&album_id=${props.albumId}` : ''
params += props.trackId ? `&track_id=${props.trackId}` : ""; params += props.trackId ? `&track_id=${props.trackId}` : ''
return ( return (
<div className="text-sm sm:text-[16px]"> <div className="text-sm sm:text-[16px]">
<h2 className="hover:underline"> <h2 className="hover:underline">
<Link to={`/listens?period=all_time${params}`}>Last Played</Link> <Link to={`/listens?period=all_time${params}`}>Last Played</Link>
</h2> </h2>
<table className="-ml-4"> <table className="-ml-4">
<tbody> <tbody>
{props.showNowPlaying && npData && npData.currently_playing && ( {props.showNowPlaying && nowPlaying &&
<tr className="group hover:bg-[--color-bg-secondary]"> <tr className="group hover:bg-[--color-bg-secondary]">
<td className="w-[18px] pr-2 align-middle"></td> <td className="w-[18px] pr-2 align-middle" >
<td className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"> </td>
Now Playing <td
</td> className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]"> >
{props.hideArtists ? null : ( Now Playing
<> </td>
<ArtistLinks artists={npData.track.artists} /> {" "} <td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
</> {props.hideArtists ? null : (
)} <>
<Link <ArtistLinks artists={nowPlaying.artists} /> {' '}
className="hover:text-[--color-fg-secondary]" </>
to={`/track/${npData.track.id}`} )}
> <Link
{npData.track.title} className="hover:text-[--color-fg-secondary]"
</Link> to={`/track/${nowPlaying.id}`}
</td> >
</tr> {nowPlaying.title}
)} </Link>
{listens.map((item) => ( </td>
<tr </tr>
key={`last_listen_${item.time}`} }
className="group hover:bg-[--color-bg-secondary]" {listens.map((item) => (
> <tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
<td className="w-[18px] pr-2 align-middle"> <td className="w-[18px] pr-2 align-middle" >
<button <button
onClick={() => handleDelete(item)} onClick={() => handleDelete(item)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)" className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
aria-label="Delete" aria-label="Delete"
hidden={user === null || user === undefined} hidden={user === null || user === undefined}
> >
× ×
</button> </button>
</td> </td>
<td <td
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0" className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
title={new Date(item.time).toString()} title={new Date(item.time).toString()}
> >
{timeSince(new Date(item.time))} {timeSince(new Date(item.time))}
</td> </td>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]"> <td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
{props.hideArtists ? null : ( {props.hideArtists ? null : (
<> <>
<ArtistLinks artists={item.track.artists} /> {" "} <ArtistLinks artists={item.track.artists} /> {' '}
</> </>
)} )}
<Link <Link
className="hover:text-[--color-fg-secondary]" className="hover:text-[--color-fg-secondary]"
to={`/track/${item.track.id}`} to={`/track/${item.track.id}`}
> >
{item.track.title} {item.track.title}
</Link> </Link>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
); )
} }

@ -1,78 +1,65 @@
import { useState } from "react"; import { 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 { 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, resetTheme } = useTheme()
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " "))
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)
}
}
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme(); return (
const [custom, setCustom] = useState( <div className='flex flex-col gap-10'>
JSON.stringify(getCustomTheme() ?? initialTheme, null, " ") <div>
); <div className='flex items-center gap-3'>
<h2>Select Theme</h2>
const handleCustomTheme = () => { <div className='mb-3'>
console.log(custom); <AsyncButton onClick={resetTheme}>Reset</AsyncButton>
try { </div>
const themeData = JSON.parse(custom); </div>
setCustomTheme(themeData); <div className="grid grid-cols-2 items-center gap-2">
setCustom(JSON.stringify(themeData, null, " ")); {Object.entries(themes).map(([name, themeData]) => (
console.log(themeData); <ThemeOption setTheme={setTheme} key={name} theme={themeData} themeName={name} />
} catch (err) { ))}
console.log(err); </div>
} </div>
}; <div>
<h2>Use Custom Theme</h2>
return ( <div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
<div className="flex flex-col gap-10"> <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 className="flex items-center gap-3"> </div>
<h2>Select Theme</h2> </div>
<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> );
</div>
);
} }

@ -23,19 +23,16 @@ export const useAppContext = () => {
export const AppProvider = ({ children }: { children: React.ReactNode }) => { export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<User | null | undefined>(undefined); const [user, setUser] = useState<User | null | undefined>(undefined);
const [defaultTheme, setDefaultTheme] = useState<string | undefined>( const [defaultTheme, setDefaultTheme] = useState<string | undefined>(undefined)
undefined const [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false);
);
const [configurableHomeActivity, setConfigurableHomeActivity] =
useState<boolean>(false);
const [homeItems, setHomeItems] = useState<number>(0); const [homeItems, setHomeItems] = useState<number>(0);
const setUsername = (value: string) => { const setUsername = (value: string) => {
if (!user) { if (!user) {
return; return
} }
setUser({ ...user, username: value }); setUser({...user, username: value})
}; }
useEffect(() => { useEffect(() => {
fetch("/apis/web/v1/user/me") fetch("/apis/web/v1/user/me")
@ -48,14 +45,14 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
setConfigurableHomeActivity(true); setConfigurableHomeActivity(true);
setHomeItems(12); setHomeItems(12);
getCfg().then((cfg) => { getCfg().then(cfg => {
console.log(cfg); console.log(cfg)
if (cfg.default_theme !== "") { if (cfg.default_theme !== '') {
setDefaultTheme(cfg.default_theme); setDefaultTheme(cfg.default_theme)
} else { } else {
setDefaultTheme("yuu"); setDefaultTheme('yuu')
} }
}); })
}, []); }, []);
// Block rendering the app until config is loaded // Block rendering the app until config is loaded
@ -73,7 +70,5 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
setUsername, setUsername,
}; };
return ( return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
<AppContext.Provider value={contextValue}>{children}</AppContext.Provider> };
);
};

@ -1,131 +1,127 @@
import { import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react';
createContext, import { type Theme, themes } from '~/styles/themes.css';
useEffect, import { themeVars } from '~/styles/vars.css';
useState, import { useAppContext } from './AppProvider';
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 {
themeName: string; themeName: string;
theme: Theme; theme: Theme;
setTheme: (theme: string) => void; setTheme: (theme: string) => void;
resetTheme: () => void; resetTheme: () => void;
setCustomTheme: (theme: Theme) => void; setCustomTheme: (theme: Theme) => void;
getCustomTheme: () => Theme | undefined; 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);
} }
} }
function getStoredCustomTheme(): Theme | undefined { function getStoredCustomTheme(): Theme | undefined {
const themeStr = localStorage.getItem("custom-theme"); const themeStr = localStorage.getItem('custom-theme');
if (!themeStr) return undefined; if (!themeStr) return undefined;
try { try {
const parsed = JSON.parse(themeStr); const parsed = JSON.parse(themeStr);
const { name, ...theme } = parsed; const { name, ...theme } = parsed;
return theme as Theme; return theme as Theme;
} catch { } catch {
return undefined; return undefined;
} }
} }
export function ThemeProvider({ children }: { children: ReactNode }) { export function ThemeProvider({
let defaultTheme = useAppContext().defaultTheme; children,
let initialTheme = localStorage.getItem("theme") ?? defaultTheme; }: {
const [themeName, setThemeName] = useState(initialTheme); children: ReactNode;
const [currentTheme, setCurrentTheme] = useState<Theme>(() => { }) {
if (initialTheme === "custom") { let defaultTheme = useAppContext().defaultTheme
const customTheme = getStoredCustomTheme(); let initialTheme = localStorage.getItem("theme") ?? defaultTheme
return customTheme || themes[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);
}
}
} }
return themes[initialTheme] || themes[defaultTheme];
}); const resetTheme = () => {
setThemeName(defaultTheme)
const setTheme = (newThemeName: string) => { localStorage.removeItem('theme')
setThemeName(newThemeName); setCurrentTheme(themes[defaultTheme])
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);
setThemeName('custom');
localStorage.setItem('theme', 'custom')
setCurrentTheme(customTheme);
}, []);
if (themeName === "custom") { const getCustomTheme = (): Theme | undefined => {
applyCustomThemeVars(currentTheme); return getStoredCustomTheme();
} else {
clearCustomThemeVars();
} }
}, [themeName, currentTheme]);
useEffect(() => {
return ( const root = document.documentElement;
<ThemeContext.Provider
value={{ root.setAttribute('data-theme', themeName);
themeName,
theme: currentTheme, if (themeName === 'custom') {
setTheme, applyCustomThemeVars(currentTheme);
resetTheme, } else {
setCustomTheme, clearCustomThemeVars();
getCustomTheme, }
}} }, [themeName, currentTheme]);
>
{children} return (
</ThemeContext.Provider> <ThemeContext.Provider value={{
); themeName,
theme: currentTheme,
setTheme,
resetTheme,
setCustomTheme,
getCustomTheme
}}>
{children}
</ThemeContext.Provider>
);
} }
export { ThemeContext }; export { ThemeContext };

@ -40,9 +40,6 @@ If the environment variable is defined without **and** with the suffix at the sa
##### KOITO_LOG_LEVEL ##### KOITO_LOG_LEVEL
- Default: `info` - Default: `info`
- Description: One of `debug | info | warn | error | fatal` - Description: One of `debug | info | warn | error | fatal`
##### KOITO_ARTIST_SEPARATORS_REGEX
- Default: `\s+·\s+`
- Description: The list of regex patterns Koito will use to separate artist strings, separated by two semicolons (`;;`).
##### KOITO_MUSICBRAINZ_URL ##### KOITO_MUSICBRAINZ_URL
- Default: `https://musicbrainz.org` - Default: `https://musicbrainz.org`
- Description: The URL Koito will use to contact MusicBrainz. Replace this value if you have your own MusicBrainz mirror. - Description: The URL Koito will use to contact MusicBrainz. Replace this value if you have your own MusicBrainz mirror.

@ -62,7 +62,7 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
} }
if len(result) < 1 { if len(result) < 1 {
allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle, cfg.ArtistSeparators())) allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
l.Debug().Msgf("Associating artists by artist name(s) %v and track title '%s'", allArtists, opts.TrackTitle) l.Debug().Msgf("Associating artists by artist name(s) %v and track title '%s'", allArtists, opts.TrackTitle)
fallbackMatches, err := matchArtistsByNames(ctx, allArtists, nil, d, opts) fallbackMatches, err := matchArtistsByNames(ctx, allArtists, nil, d, opts)
if err != nil { if err != nil {
@ -180,7 +180,7 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts,
} }
if len(opts.ArtistNames) < 1 { if len(opts.ArtistNames) < 1 {
opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle, cfg.ArtistSeparators())) opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
} }
a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts) a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts)

@ -201,18 +201,21 @@ func buildArtistStr(artists []*models.Artist) string {
var ( var (
// Bracketed feat patterns // Bracketed feat patterns
bracketFeatPatterns = []*regexp.Regexp{ bracketFeatPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\([fF]eat\. ([^)]*)\)`), regexp.MustCompile(`(?i)\(feat\. ([^)]*)\)`),
regexp.MustCompile(`(?i)\[[fF]eat\. ([^\]]*)\]`), regexp.MustCompile(`(?i)\[feat\. ([^\]]*)\]`),
} }
// Inline feat (not in brackets) // Inline feat (not in brackets)
inlineFeatPattern = regexp.MustCompile(`(?i)[fF]eat\. ([^()\[\]]+)$`) inlineFeatPattern = regexp.MustCompile(`(?i)feat\. ([^()\[\]]+)$`)
// Delimiters only used inside feat. sections // Delimiters only used inside feat. sections
featSplitDelimiters = regexp.MustCompile(`(?i)\s*(?:,|&|and|·)\s*`) featSplitDelimiters = regexp.MustCompile(`(?i)\s*(?:,|&|and|·)\s*`)
// Delimiter for separating artists in main string (rare but real usage)
mainArtistDotSplitter = regexp.MustCompile(`\s+·\s+`)
) )
// ParseArtists extracts all contributing artist names from the artist and title strings // ParseArtists extracts all contributing artist names from the artist and title strings
func ParseArtists(artist string, title string, addlSeparators []*regexp.Regexp) []string { func ParseArtists(artist string, title string) []string {
seen := make(map[string]struct{}) seen := make(map[string]struct{})
var out []string var out []string
@ -227,9 +230,12 @@ func ParseArtists(artist string, title string, addlSeparators []*regexp.Regexp)
} }
} }
foundFeat := false
// Extract bracketed features from artist // Extract bracketed features from artist
for _, re := range bracketFeatPatterns { for _, re := range bracketFeatPatterns {
if matches := re.FindStringSubmatch(artist); matches != nil { if matches := re.FindStringSubmatch(artist); matches != nil {
foundFeat = true
artist = strings.Replace(artist, matches[0], "", 1) artist = strings.Replace(artist, matches[0], "", 1)
for _, name := range featSplitDelimiters.Split(matches[1], -1) { for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name) add(name)
@ -238,6 +244,7 @@ func ParseArtists(artist string, title string, addlSeparators []*regexp.Regexp)
} }
// Extract inline feat. from artist // Extract inline feat. from artist
if matches := inlineFeatPattern.FindStringSubmatch(artist); matches != nil { if matches := inlineFeatPattern.FindStringSubmatch(artist); matches != nil {
foundFeat = true
artist = strings.Replace(artist, matches[0], "", 1) artist = strings.Replace(artist, matches[0], "", 1)
for _, name := range featSplitDelimiters.Split(matches[1], -1) { for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name) add(name)
@ -245,19 +252,14 @@ func ParseArtists(artist string, title string, addlSeparators []*regexp.Regexp)
} }
// Add base artist(s) // Add base artist(s)
l1 := len(out) if foundFeat {
for _, re := range addlSeparators { add(strings.TrimSpace(artist))
for _, name := range re.Split(artist, -1) { } else {
if name == artist { // Only split on " · " in base artist string
continue for _, name := range mainArtistDotSplitter.Split(artist, -1) {
}
add(name) add(name)
} }
} }
// Only add the full artist string if no splitters were matched
if l1 == len(out) {
add(artist)
}
// Extract features from title // Extract features from title
for _, re := range bracketFeatPatterns { for _, re := range bracketFeatPatterns {

@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"regexp"
"testing" "testing"
"time" "time"
@ -168,15 +167,15 @@ func getTestGetenv(resource *dockertest.Resource) func(string) string {
func truncateTestData(t *testing.T) { func truncateTestData(t *testing.T) {
err := store.Exec(context.Background(), err := store.Exec(context.Background(),
`TRUNCATE `TRUNCATE
artists, artists,
artist_aliases, artist_aliases,
tracks, tracks,
artist_tracks, artist_tracks,
releases, releases,
artist_releases, artist_releases,
release_aliases, release_aliases,
listens listens
RESTART IDENTITY CASCADE`) RESTART IDENTITY CASCADE`)
require.NoError(t, err) require.NoError(t, err)
} }
@ -185,23 +184,23 @@ func setupTestDataWithMbzIDs(t *testing.T) {
truncateTestData(t) truncateTestData(t)
err := store.Exec(context.Background(), err := store.Exec(context.Background(),
`INSERT INTO artists (musicbrainz_id) `INSERT INTO artists (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000001')`) VALUES ('00000000-0000-0000-0000-000000000001')`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary) `INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`) VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO releases (musicbrainz_id) `INSERT INTO releases (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000101')`) VALUES ('00000000-0000-0000-0000-000000000101')`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO release_aliases (release_id, alias, source, is_primary) `INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'AG! Calling', 'Testing', true)`) VALUES (1, 'AG! Calling', 'Testing', true)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO artist_releases (artist_id, release_id) `INSERT INTO artist_releases (artist_id, release_id)
VALUES (1, 1)`) VALUES (1, 1)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
@ -222,23 +221,23 @@ func setupTestDataSansMbzIDs(t *testing.T) {
truncateTestData(t) truncateTestData(t)
err := store.Exec(context.Background(), err := store.Exec(context.Background(),
`INSERT INTO artists (musicbrainz_id) `INSERT INTO artists (musicbrainz_id)
VALUES (NULL)`) VALUES (NULL)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary) `INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`) VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO releases (musicbrainz_id) `INSERT INTO releases (musicbrainz_id)
VALUES (NULL)`) VALUES (NULL)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO release_aliases (release_id, alias, source, is_primary) `INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'AG! Calling', 'Testing', true)`) VALUES (1, 'AG! Calling', 'Testing', true)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO artist_releases (artist_id, release_id) `INSERT INTO artist_releases (artist_id, release_id)
VALUES (1, 1)`) VALUES (1, 1)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
@ -359,16 +358,10 @@ func TestArtistStringParse(t *testing.T) {
// artists in both // artists in both
{"Daft Punk feat. Julian Casablancas", "Instant Crush (feat. Julian Casablancas)"}: {"Daft Punk", "Julian Casablancas"}, {"Daft Punk feat. Julian Casablancas", "Instant Crush (feat. Julian Casablancas)"}: {"Daft Punk", "Julian Casablancas"},
{"Paramore (feat. Joy Williams)", "Hate to See Your Heart Break feat. Joy Williams"}: {"Paramore", "Joy Williams"}, {"Paramore (feat. Joy Williams)", "Hate to See Your Heart Break feat. Joy Williams"}: {"Paramore", "Joy Williams"},
{"MINSU", "오해 금지 (Feat. BIG Naughty)"}: {"MINSU", "BIG Naughty"},
{"MINSU", "오해 금지 [Feat. BIG Naughty]"}: {"MINSU", "BIG Naughty"},
{"MINSU", "오해 금지 Feat. BIG Naughty"}: {"MINSU", "BIG Naughty"},
// custom separator
{"MIMiNARI//楠木ともり", "眠れない"}: {"MIMiNARI", "楠木ともり"},
} }
for in, out := range cases { for in, out := range cases {
artists := catalog.ParseArtists(in.Name, in.Title, []*regexp.Regexp{regexp.MustCompile(`\s*//\s*`), regexp.MustCompile(`\s+·\s+`)}) artists := catalog.ParseArtists(in.Name, in.Title)
assert.ElementsMatch(t, out, artists) assert.ElementsMatch(t, out, artists)
} }
} }

@ -3,7 +3,6 @@ package cfg
import ( import (
"errors" "errors"
"fmt" "fmt"
"regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -46,7 +45,6 @@ const (
IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX" IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX"
IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX" IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX"
FETCH_IMAGES_DURING_IMPORT_ENV = "KOITO_FETCH_IMAGES_DURING_IMPORT" FETCH_IMAGES_DURING_IMPORT_ENV = "KOITO_FETCH_IMAGES_DURING_IMPORT"
ARTIST_SEPARATORS_ENV = "KOITO_ARTIST_SEPARATORS_REGEX"
) )
type config struct { type config struct {
@ -82,7 +80,6 @@ type config struct {
userAgent string userAgent string
importBefore time.Time importBefore time.Time
importAfter time.Time importAfter time.Time
artistSeparators []*regexp.Regexp
} }
var ( var (
@ -192,18 +189,6 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
rawCors := getenv(CORS_ORIGINS_ENV) rawCors := getenv(CORS_ORIGINS_ENV)
cfg.allowedOrigins = strings.Split(rawCors, ",") cfg.allowedOrigins = strings.Split(rawCors, ",")
if getenv(ARTIST_SEPARATORS_ENV) != "" {
for pattern := range strings.SplitSeq(getenv(ARTIST_SEPARATORS_ENV), ";;") {
regex, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("failed to compile regex pattern %s", pattern)
}
cfg.artistSeparators = append(cfg.artistSeparators, regex)
}
} else {
cfg.artistSeparators = []*regexp.Regexp{regexp.MustCompile(`\s+·\s+`)}
}
switch strings.ToLower(getenv(LOG_LEVEL_ENV)) { switch strings.ToLower(getenv(LOG_LEVEL_ENV)) {
case "debug": case "debug":
cfg.logLevel = 0 cfg.logLevel = 0
@ -403,9 +388,3 @@ func FetchImagesDuringImport() bool {
defer lock.RUnlock() defer lock.RUnlock()
return globalConfig.fetchImageDuringImport return globalConfig.fetchImageDuringImport
} }
func ArtistSeparators() []*regexp.Regexp {
lock.RLock()
defer lock.RUnlock()
return globalConfig.artistSeparators
}

Loading…
Cancel
Save