Compare commits

...

6 Commits

Author SHA1 Message Date
Gabe Farrell 0186cefb52 fix: rebase errors
3 weeks ago
Gabe Farrell 68d922ce55 feat: add server-side configuration with default theme (#90)
3 weeks ago
Michael Landry 621ca63b6b Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name
3 weeks ago
Michael Landry 517cc8ac28 Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening
3 weeks ago
Gabe Farrell 56ffe0a041
feat: custom artist separator regex (#95)
3 weeks ago
Gabe Farrell 164a9dc56f fix: useQuery instead of useEffect for now playing
3 weeks ago

@ -1,129 +1,170 @@
interface getItemsArgs { interface getItemsArgs {
limit: number, limit: number;
period: string, period: string;
page: number, page: number;
artist_id?: number, artist_id?: number;
album_id?: number, album_id?: number;
track_id?: number track_id?: number;
} }
interface getActivityArgs { interface getActivityArgs {
step: string step: string;
range: number range: number;
month: number month: number;
year: number year: number;
artist_id: number artist_id: number;
album_id: number album_id: number;
track_id: number track_id: number;
} }
function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> { function getLastListens(
return fetch(`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Listen>>) args: getItemsArgs
): Promise<PaginatedResponse<Listen>> {
return fetch(
`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Listen>>);
} }
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> { function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> {
if (args.artist_id) { if (args.artist_id) {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>) return fetch(
`/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) { } 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>>) 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 { } 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>>) return fetch(
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
} }
} }
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> { function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> {
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}` const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
if (args.artist_id) { if (args.artist_id) {
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>) return fetch(baseUri + `&artist_id=${args.artist_id}`).then(
(r) => r.json() as Promise<PaginatedResponse<Album>>
);
} else { } else {
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>) return fetch(baseUri).then(
(r) => r.json() as Promise<PaginatedResponse<Album>>
);
} }
} }
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> { function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}` const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`;
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Artist>>) return fetch(baseUri).then(
(r) => r.json() as Promise<PaginatedResponse<Artist>>
);
} }
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> { function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
return fetch(`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`).then(r => r.json() as Promise<ListenActivityItem[]>) return fetch(
`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
).then((r) => r.json() as Promise<ListenActivityItem[]>);
} }
function getStats(period: string): Promise<Stats> { function getStats(period: string): Promise<Stats> {
return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>) return fetch(`/apis/web/v1/stats?period=${period}`).then(
(r) => r.json() as Promise<Stats>
);
} }
function search(q: string): Promise<SearchResponse> { function search(q: string): Promise<SearchResponse> {
q = encodeURIComponent(q) q = encodeURIComponent(q);
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>) return fetch(`/apis/web/v1/search?q=${q}`).then(
(r) => r.json() as Promise<SearchResponse>
);
} }
function imageUrl(id: string, size: string) { function imageUrl(id: string, size: string) {
if (!id) { if (!id) {
id = 'default' id = "default";
} }
return `/images/${size}/${id}` return `/images/${size}/${id}`;
} }
function replaceImage(form: FormData): Promise<Response> { function replaceImage(form: FormData): Promise<Response> {
return fetch(`/apis/web/v1/replace-image`, { return fetch(`/apis/web/v1/replace-image`, {
method: "POST", method: "POST",
body: form, body: form,
}) });
} }
function mergeTracks(from: number, to: number): Promise<Response> { function mergeTracks(from: number, to: number): Promise<Response> {
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, { return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
method: "POST", method: "POST",
}) });
} }
function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise<Response> { function mergeAlbums(
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, { from: number,
to: number,
replaceImage: boolean
): Promise<Response> {
return fetch(
`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
{
method: "POST", 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}`, { }
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", method: "POST",
})
} }
function login(username: string, password: string, remember: boolean): Promise<Response> { );
const form = new URLSearchParams }
form.append('username', username) function login(
form.append('password', password) username: string,
form.append('remember_me', String(remember)) 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`, { return fetch(`/apis/web/v1/login`, {
method: "POST", method: "POST",
body: form, 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((r) => r.json() as Promise<ApiKey[]>) return fetch(`/apis/web/v1/user/apikeys`).then(
(r) => r.json() as Promise<ApiKey[]>
);
} }
const createApiKey = async (label: string): Promise<ApiKey> => { const createApiKey = async (label: string): Promise<ApiKey> => {
const form = new URLSearchParams const form = new URLSearchParams();
form.append('label', label) form.append("label", label);
const r = await fetch(`/apis/web/v1/user/apikeys`, { const r = await fetch(`/apis/web/v1/user/apikeys`, {
method: "POST", method: "POST",
body: form, body: form,
@ -132,7 +173,7 @@ const createApiKey = async (label: string): Promise<ApiKey> => {
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) {
@ -145,75 +186,94 @@ const createApiKey = async (label: string): Promise<ApiKey> => {
}; };
function deleteApiKey(id: number): Promise<Response> { function deleteApiKey(id: number): Promise<Response> {
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, { return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function updateApiKeyLabel(id: number, label: string): Promise<Response> { function updateApiKeyLabel(id: number, label: string): Promise<Response> {
const form = new URLSearchParams const form = new URLSearchParams();
form.append('id', String(id)) form.append("id", String(id));
form.append('label', label) form.append("label", label);
return fetch(`/apis/web/v1/user/apikeys`, { return fetch(`/apis/web/v1/user/apikeys`, {
method: "PATCH", method: "PATCH",
body: form, body: form,
}) });
} }
function deleteItem(itemType: string, id: number): Promise<Response> { function deleteItem(itemType: string, id: number): Promise<Response> {
return fetch(`/apis/web/v1/${itemType}?id=${id}`, { return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function updateUser(username: string, password: string) { function updateUser(username: string, password: string) {
const form = new URLSearchParams const form = new URLSearchParams();
form.append('username', username) form.append("username", username);
form.append('password', password) form.append("password", password);
return fetch(`/apis/web/v1/user`, { return fetch(`/apis/web/v1/user`, {
method: "PATCH", method: "PATCH",
body: form, body: form,
}) });
} }
function getAliases(type: string, id: number): Promise<Alias[]> { function getAliases(type: string, id: number): Promise<Alias[]> {
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise<Alias[]>) return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(
(r) => r.json() as Promise<Alias[]>
);
} }
function createAlias(type: string, id: number, alias: string): Promise<Response> { function createAlias(
const form = new URLSearchParams type: string,
form.append(`${type}_id`, String(id)) id: number,
form.append('alias', alias) alias: string
): Promise<Response> {
const form = new URLSearchParams();
form.append(`${type}_id`, String(id));
form.append("alias", alias);
return fetch(`/apis/web/v1/aliases`, { return fetch(`/apis/web/v1/aliases`, {
method: 'POST', method: "POST",
body: form, body: form,
}) });
} }
function deleteAlias(type: string, id: number, alias: string): Promise<Response> { function deleteAlias(
const form = new URLSearchParams type: string,
form.append(`${type}_id`, String(id)) id: number,
form.append('alias', alias) 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`, { 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> { function setPrimaryAlias(
const form = new URLSearchParams type: string,
form.append(`${type}_id`, String(id)) id: number,
form.append('alias', alias) 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`, { return fetch(`/apis/web/v1/aliases/primary`, {
method: "POST", method: "POST",
body: form, body: form,
}) });
} }
function getAlbum(id: number): Promise<Album> { function getAlbum(id: number): Promise<Album> {
return fetch(`/apis/web/v1/album?id=${id}`).then(r => r.json() as Promise<Album>) return fetch(`/apis/web/v1/album?id=${id}`).then(
(r) => r.json() as Promise<Album>
);
} }
function deleteListen(listen: Listen): Promise<Response> { function deleteListen(listen: Listen): Promise<Response> {
const ms = new Date(listen.time).getTime() const ms = new Date(listen.time).getTime();
const unix = Math.floor(ms / 1000); const unix = Math.floor(ms / 1000);
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, { return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function getExport() { function getExport() {}
function getNowPlaying(): Promise<NowPlaying> {
return fetch("/apis/web/v1/now-playing").then((r) => r.json());
} }
export { export {
@ -246,94 +306,99 @@ export {
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,
@ -349,5 +414,6 @@ export type {
Alias, Alias,
ApiKey, ApiKey,
ApiError, ApiError,
Config Config,
} NowPlaying,
};

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

@ -1,61 +1,64 @@
import { useEffect, useState } from "react" import { 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 { deleteListen, getLastListens, type getItemsArgs, type Listen, type Track } from "api/api" import {
import { Link } from "react-router" deleteListen,
import { useAppContext } from "~/providers/AppProvider" getLastListens,
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: ['last-listens', { queryKey: [
"last-listens",
{
limit: props.limit, limit: props.limit,
period: 'all_time', period: "all_time",
artist_id: props.artistId, artist_id: props.artistId,
album_id: props.albumId, album_id: props.albumId,
track_id: props.trackId 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 [items, setItems] = useState<Listen[] | null>(null)
const [nowPlaying, setNowPlaying] = useState<Track | undefined>(undefined)
useEffect(() => {
fetch('/apis/web/v1/now-playing')
.then(r => r.json())
.then(r => {
console.log(r)
if (r.currently_playing) {
setNowPlaying(r.track)
}
})
}, [])
const handleDelete = async (listen: Listen) => { const handleDelete = async (listen: Listen) => {
if (!data) return if (!data) return;
try { try {
const res = await deleteListen(listen) const res = await deleteListen(listen);
if (res.ok || (res.status >= 200 && res.status < 300)) { if (res.ok || (res.status >= 200 && res.status < 300)) {
setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time)) setItems((prev) =>
(prev ?? data.items).filter((i) => i.time !== listen.time)
);
} else { } else {
console.error("Failed to delete listen:", res.status) console.error("Failed to delete listen:", res.status);
} }
} catch (err) { } catch (err) {
console.error("Error deleting listen:", err) console.error("Error deleting listen:", err);
}
} }
};
if (isPending) { if (isPending) {
return ( return (
@ -63,18 +66,18 @@ export default function LastPlays(props: Props) {
<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]">
@ -83,32 +86,32 @@ export default function LastPlays(props: Props) {
</h2> </h2>
<table className="-ml-4"> <table className="-ml-4">
<tbody> <tbody>
{props.showNowPlaying && nowPlaying && {props.showNowPlaying && npData && npData.currently_playing && (
<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 className="w-[18px] pr-2 align-middle"></td>
</td> <td className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0">
<td
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
>
Now Playing Now Playing
</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={nowPlaying.artists} /> {' '} <ArtistLinks artists={npData.track.artists} /> {" "}
</> </>
)} )}
<Link <Link
className="hover:text-[--color-fg-secondary]" className="hover:text-[--color-fg-secondary]"
to={`/track/${nowPlaying.id}`} to={`/track/${npData.track.id}`}
> >
{nowPlaying.title} {npData.track.title}
</Link> </Link>
</td> </td>
</tr> </tr>
} )}
{listens.map((item) => ( {listens.map((item) => (
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]"> <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)}
@ -128,7 +131,7 @@ export default function LastPlays(props: Props) {
<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
@ -143,5 +146,5 @@ export default function LastPlays(props: Props) {
</tbody> </tbody>
</table> </table>
</div> </div>
) );
} }

@ -1,8 +1,8 @@
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();
@ -21,42 +21,55 @@ export function ThemeSwitcher() {
warning: "#f5b851", warning: "#f5b851",
success: "#8fc48f", success: "#8fc48f",
info: "#87b8dd", info: "#87b8dd",
} };
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme() const { setCustomTheme, getCustomTheme, resetTheme } = useTheme();
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")) const [custom, setCustom] = useState(
JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")
);
const handleCustomTheme = () => { const handleCustomTheme = () => {
console.log(custom) console.log(custom);
try { try {
const themeData = JSON.parse(custom) const themeData = JSON.parse(custom);
setCustomTheme(themeData) setCustomTheme(themeData);
setCustom(JSON.stringify(themeData, null, " ")) setCustom(JSON.stringify(themeData, null, " "));
console.log(themeData) console.log(themeData);
} catch (err) { } catch (err) {
console.log(err) console.log(err);
}
} }
};
return ( return (
<div className='flex flex-col gap-10'> <div className="flex flex-col gap-10">
<div> <div>
<div className='flex items-center gap-3'> <div className="flex items-center gap-3">
<h2>Select Theme</h2> <h2>Select Theme</h2>
<div className='mb-3'> <div className="mb-3">
<AsyncButton onClick={resetTheme}>Reset</AsyncButton> <AsyncButton onClick={resetTheme}>Reset</AsyncButton>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 items-center gap-2"> <div className="grid grid-cols-2 items-center gap-2">
{Object.entries(themes).map(([name, themeData]) => ( {Object.entries(themes).map(([name, themeData]) => (
<ThemeOption setTheme={setTheme} key={name} theme={themeData} themeName={name} /> <ThemeOption
setTheme={setTheme}
key={name}
theme={themeData}
themeName={name}
/>
))} ))}
</div> </div>
</div> </div>
<div> <div>
<h2>Use Custom Theme</h2> <h2>Use Custom Theme</h2>
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg"> <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} /> <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> <AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
</div> </div>
</div> </div>

@ -23,16 +23,19 @@ 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>(undefined) const [defaultTheme, setDefaultTheme] = useState<string | undefined>(
const [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false); undefined
);
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")
@ -45,14 +48,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
@ -70,5 +73,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
setUsername, setUsername,
}; };
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>; return (
<AppContext.Provider value={contextValue}>{children}</AppContext.Provider>
);
}; };

@ -1,7 +1,13 @@
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react'; import {
import { type Theme, themes } from '~/styles/themes.css'; createContext,
import { themeVars } from '~/styles/vars.css'; useEffect,
import { useAppContext } from './AppProvider'; useState,
useCallback,
type ReactNode,
} from "react";
import { type Theme, themes } from "~/styles/themes.css";
import { themeVars } from "~/styles/vars.css";
import { useAppContext } from "./AppProvider";
interface ThemeContextValue { interface ThemeContextValue {
themeName: string; themeName: string;
@ -15,13 +21,13 @@ interface ThemeContextValue {
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);
} }
} }
@ -33,7 +39,7 @@ function clearCustomThemeVars() {
} }
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);
@ -44,16 +50,12 @@ function getStoredCustomTheme(): Theme | undefined {
} }
} }
export function ThemeProvider({ export function ThemeProvider({ children }: { children: ReactNode }) {
children, let defaultTheme = useAppContext().defaultTheme;
}: { let initialTheme = localStorage.getItem("theme") ?? defaultTheme;
children: ReactNode;
}) {
let defaultTheme = useAppContext().defaultTheme
let initialTheme = localStorage.getItem("theme") ?? defaultTheme
const [themeName, setThemeName] = useState(initialTheme); const [themeName, setThemeName] = useState(initialTheme);
const [currentTheme, setCurrentTheme] = useState<Theme>(() => { const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
if (initialTheme === 'custom') { if (initialTheme === "custom") {
const customTheme = getStoredCustomTheme(); const customTheme = getStoredCustomTheme();
return customTheme || themes[defaultTheme]; return customTheme || themes[defaultTheme];
} }
@ -62,7 +64,7 @@ export function ThemeProvider({
const setTheme = (newThemeName: string) => { const setTheme = (newThemeName: string) => {
setThemeName(newThemeName); setThemeName(newThemeName);
if (newThemeName === 'custom') { if (newThemeName === "custom") {
const customTheme = getStoredCustomTheme(); const customTheme = getStoredCustomTheme();
if (customTheme) { if (customTheme) {
setCurrentTheme(customTheme); setCurrentTheme(customTheme);
@ -74,36 +76,36 @@ export function ThemeProvider({
} else { } else {
const foundTheme = themes[newThemeName]; const foundTheme = themes[newThemeName];
if (foundTheme) { if (foundTheme) {
localStorage.setItem('theme', newThemeName) localStorage.setItem("theme", newThemeName);
setCurrentTheme(foundTheme); setCurrentTheme(foundTheme);
} }
} }
} };
const resetTheme = () => { const resetTheme = () => {
setThemeName(defaultTheme) setThemeName(defaultTheme);
localStorage.removeItem('theme') localStorage.removeItem("theme");
setCurrentTheme(themes[defaultTheme]) setCurrentTheme(themes[defaultTheme]);
} };
const setCustomTheme = useCallback((customTheme: Theme) => { const setCustomTheme = useCallback((customTheme: Theme) => {
localStorage.setItem('custom-theme', JSON.stringify(customTheme)); localStorage.setItem("custom-theme", JSON.stringify(customTheme));
applyCustomThemeVars(customTheme); applyCustomThemeVars(customTheme);
setThemeName('custom'); setThemeName("custom");
localStorage.setItem('theme', 'custom') localStorage.setItem("theme", "custom");
setCurrentTheme(customTheme); setCurrentTheme(customTheme);
}, []); }, []);
const getCustomTheme = (): Theme | undefined => { const getCustomTheme = (): Theme | undefined => {
return getStoredCustomTheme(); return getStoredCustomTheme();
} };
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
root.setAttribute('data-theme', themeName); root.setAttribute("data-theme", themeName);
if (themeName === 'custom') { if (themeName === "custom") {
applyCustomThemeVars(currentTheme); applyCustomThemeVars(currentTheme);
} else { } else {
clearCustomThemeVars(); clearCustomThemeVars();
@ -111,14 +113,16 @@ export function ThemeProvider({
}, [themeName, currentTheme]); }, [themeName, currentTheme]);
return ( return (
<ThemeContext.Provider value={{ <ThemeContext.Provider
value={{
themeName, themeName,
theme: currentTheme, theme: currentTheme,
setTheme, setTheme,
resetTheme, resetTheme,
setCustomTheme, setCustomTheme,
getCustomTheme getCustomTheme,
}}> }}
>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
); );

@ -40,6 +40,9 @@ 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)) allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle, cfg.ArtistSeparators()))
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)) opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle, cfg.ArtistSeparators()))
} }
a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts) a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts)

@ -201,21 +201,18 @@ func buildArtistStr(artists []*models.Artist) string {
var ( var (
// Bracketed feat patterns // Bracketed feat patterns
bracketFeatPatterns = []*regexp.Regexp{ bracketFeatPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\(feat\. ([^)]*)\)`), regexp.MustCompile(`(?i)\([fF]eat\. ([^)]*)\)`),
regexp.MustCompile(`(?i)\[feat\. ([^\]]*)\]`), regexp.MustCompile(`(?i)\[[fF]eat\. ([^\]]*)\]`),
} }
// Inline feat (not in brackets) // Inline feat (not in brackets)
inlineFeatPattern = regexp.MustCompile(`(?i)feat\. ([^()\[\]]+)$`) inlineFeatPattern = regexp.MustCompile(`(?i)[fF]eat\. ([^()\[\]]+)$`)
// 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) []string { func ParseArtists(artist string, title string, addlSeparators []*regexp.Regexp) []string {
seen := make(map[string]struct{}) seen := make(map[string]struct{})
var out []string var out []string
@ -230,12 +227,9 @@ func ParseArtists(artist string, title string) []string {
} }
} }
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)
@ -244,7 +238,6 @@ func ParseArtists(artist string, title string) []string {
} }
// 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)
@ -252,14 +245,19 @@ func ParseArtists(artist string, title string) []string {
} }
// Add base artist(s) // Add base artist(s)
if foundFeat { l1 := len(out)
add(strings.TrimSpace(artist)) for _, re := range addlSeparators {
} else { for _, name := range re.Split(artist, -1) {
// Only split on " · " in base artist string if name == artist {
for _, name := range mainArtistDotSplitter.Split(artist, -1) { continue
}
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,6 +5,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"regexp"
"testing" "testing"
"time" "time"
@ -358,10 +359,16 @@ 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) artists := catalog.ParseArtists(in.Name, in.Title, []*regexp.Regexp{regexp.MustCompile(`\s*//\s*`), regexp.MustCompile(`\s+·\s+`)})
assert.ElementsMatch(t, out, artists) assert.ElementsMatch(t, out, artists)
} }
} }

@ -3,6 +3,7 @@ package cfg
import ( import (
"errors" "errors"
"fmt" "fmt"
"regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -45,6 +46,7 @@ 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 {
@ -80,6 +82,7 @@ type config struct {
userAgent string userAgent string
importBefore time.Time importBefore time.Time
importAfter time.Time importAfter time.Time
artistSeparators []*regexp.Regexp
} }
var ( var (
@ -189,6 +192,18 @@ 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
@ -388,3 +403,9 @@ 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