Compare commits

..

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

@ -1,170 +1,129 @@
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}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
} else if (args.album_id) { } else if (args.album_id) {
return fetch( 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>>)
`/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( 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}&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 { } else {
return fetch(baseUri).then( return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>)
(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,
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
function login( form.append('username', username)
username: string, form.append('password', password)
password: string, form.append('remember_me', String(remember))
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( 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,
@ -173,7 +132,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) {
@ -186,94 +145,75 @@ 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( return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise<Alias[]>)
(r) => r.json() as Promise<Alias[]>
);
} }
function createAlias( function createAlias(type: string, id: number, alias: string): Promise<Response> {
type: string, const form = new URLSearchParams
id: number, form.append(`${type}_id`, String(id))
alias: string form.append('alias', alias)
): 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( function deleteAlias(type: string, id: number, alias: string): Promise<Response> {
type: string, const form = new URLSearchParams
id: number, form.append(`${type}_id`, String(id))
alias: string form.append('alias', alias)
): 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( function setPrimaryAlias(type: string, id: number, alias: string): Promise<Response> {
type: string, const form = new URLSearchParams
id: number, form.append(`${type}_id`, String(id))
alias: string form.append('alias', alias)
): 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( 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 {
@ -306,99 +246,94 @@ 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,
@ -414,6 +349,5 @@ 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 { 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) { if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number); const [, r, g, b] = rgbMatch.map(Number);
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join(""); return (
'#' +
[r, g, b]
.map((n) => n.toString(16).padStart(2, '0'))
.join('')
);
} }
return value; return value;
} }
interface Props { interface Props {
step?: string; step?: string
range?: number; range?: number
month?: number; month?: number
year?: number; year?: number
artistId?: number; artistId?: number
albumId?: number; albumId?: number
trackId?: number; trackId?: number
configurable?: boolean; configurable?: boolean
autoAdjust?: boolean; autoAdjust?: boolean
} }
export default function ActivityGrid({ export default function ActivityGrid({
step = "day", step = 'day',
range = 182, range = 182,
month = 0, month = 0,
year = 0, year = 0,
@ -44,12 +44,13 @@ export default function ActivityGrid({
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({ const { isPending, isError, data, error } = useQuery({
queryKey: [ queryKey: [
"listen-activity", 'listen-activity',
{ {
step: stepState, step: stepState,
range: rangeState, range: rangeState,
@ -57,41 +58,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 = "#", var rgb = "#", c, i;
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);
} }
@ -99,39 +100,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 = const adjustment = artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1
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) * 0.8; return ((v-t) / t) * .8
}
} }
};
const CHUNK_SIZE = 26 * 7; const CHUNK_SIZE = 26 * 7;
const chunks = []; const chunks = [];
@ -166,25 +167,18 @@ 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()} ${ inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
item.listens
} plays`}
> >
<div <div
style={{ style={{
display: "inline-block", display: 'inline-block',
background: background:
item.listens > 0 item.listens > 0
? LightenDarkenColor( ? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
color, : 'var(--color-bg-secondary)',
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 item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'
? ""
: "border-[0.5px] border-(--color-bg-tertiary)"
}`} }`}
></div> ></div>
</Popup> </Popup>

@ -1,64 +1,61 @@
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, 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) => setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time))
(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 (
@ -66,18 +63,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]">
@ -86,32 +83,32 @@ export default function LastPlays(props: Props) {
</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>
<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={npData.track.artists} /> {" "} <ArtistLinks artists={nowPlaying.artists} /> {' '}
</> </>
)} )}
<Link <Link
className="hover:text-[--color-fg-secondary]" className="hover:text-[--color-fg-secondary]"
to={`/track/${npData.track.id}`} to={`/track/${nowPlaying.id}`}
> >
{npData.track.title} {nowPlaying.title}
</Link> </Link>
</td> </td>
</tr> </tr>
)} }
{listens.map((item) => ( {listens.map((item) => (
<tr <tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
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)}
@ -131,7 +128,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
@ -146,5 +143,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,55 +21,42 @@ 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( const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " "))
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 <ThemeOption setTheme={setTheme} key={name} theme={themeData} themeName={name} />
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 <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} />
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,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,13 +1,7 @@
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;
@ -21,13 +15,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);
} }
} }
@ -39,7 +33,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);
@ -50,12 +44,16 @@ function getStoredCustomTheme(): Theme | undefined {
} }
} }
export function ThemeProvider({ children }: { children: ReactNode }) { export function ThemeProvider({
let defaultTheme = useAppContext().defaultTheme; children,
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];
} }
@ -64,7 +62,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
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);
@ -76,36 +74,36 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
} 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();
@ -113,16 +111,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}, [themeName, currentTheme]); }, [themeName, currentTheme]);
return ( return (
<ThemeContext.Provider <ThemeContext.Provider value={{
value={{
themeName, themeName,
theme: currentTheme, theme: currentTheme,
setTheme, setTheme,
resetTheme, resetTheme,
setCustomTheme, setCustomTheme,
getCustomTheme, getCustomTheme
}} }}>
>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
); );

@ -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"
@ -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