Compare commits

..

2 Commits

Author SHA1 Message Date
Gabe Farrell 6a53fca8f3 chore: update workflow to eliminate dev branch
3 weeks ago
Gabe Farrell 36f984a1a2
Pre-release version v0.0.14 (#96)
3 weeks ago

@ -12,7 +12,9 @@ name: Publish Docker image
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
branches:
- main
workflow_dispatch: workflow_dispatch:
@ -21,42 +23,37 @@ jobs:
name: Go Test name: Go Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version-file: go.mod
- name: Install libvips - name: Install libvips
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libvips-dev sudo apt-get install -y libvips-dev
- name: Verify libvips install - name: Verify libvips install
run: vips --version run: vips --version
- name: Build - name: Build
run: go build -v ./... run: go build -v ./...
- name: Test - name: Test
uses: robherley/go-test-action@v0 uses: robherley/go-test-action@v0
push_to_registry: push_to_registry:
name: Push Docker image to Docker Hub name: Push Docker image to Docker Hub (release)
if: startsWith(github.ref, 'refs/tags/')
needs: test needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
steps: steps:
- name: Check out the repo - uses: actions/checkout@v4
uses: actions/checkout@v4
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
@ -64,19 +61,12 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: gabehf/koito
- name: Extract tag version - name: Extract tag version
id: extract_version
run: echo "KOITO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV run: echo "KOITO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Build and push Docker image - name: Build and push release image
id: push id: push
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@ -86,11 +76,34 @@ jobs:
gabehf/koito:${{ env.KOITO_VERSION }} gabehf/koito:${{ env.KOITO_VERSION }}
build-args: | build-args: |
KOITO_VERSION=${{ env.KOITO_VERSION }} KOITO_VERSION=${{ env.KOITO_VERSION }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push_dev:
name: Push Docker image (dev branch)
if: github.ref == 'refs/heads/main'
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate artifact attestation - name: Build and push dev image
uses: actions/attest-build-provenance@v2 uses: docker/build-push-action@v6
with: with:
subject-name: index.docker.io/gabehf/koito context: .
subject-digest: ${{ steps.push.outputs.digest }} file: ./Dockerfile
push-to-registry: true push: true
tags: |
gabehf/koito:dev
gabehf/koito:dev-${{ github.sha }}
build-args: |
KOITO_VERSION=dev
platforms: linux/amd64,linux/arm64

@ -1,327 +1,419 @@
interface getItemsArgs { interface getItemsArgs {
limit: number, limit: number;
period: string, period: string;
page: number, page: number;
artist_id?: number, artist_id?: number;
album_id?: number, album_id?: number;
track_id?: number track_id?: number;
} }
interface getActivityArgs { interface getActivityArgs {
step: string step: string;
range: number range: number;
month: number month: number;
year: number year: number;
artist_id: number artist_id: number;
album_id: number album_id: number;
track_id: number track_id: number;
} }
function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> { function getLastListens(
return fetch(`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Listen>>) args: getItemsArgs
): Promise<PaginatedResponse<Listen>> {
return fetch(
`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Listen>>);
} }
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> { function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> {
if (args.artist_id) { if (args.artist_id) {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>) return fetch(
} else if (args.album_id) { `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>) ).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
} else { } else if (args.album_id) {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>) return fetch(
} `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
} else {
return fetch(
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
}
} }
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> { function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> {
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}` const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
if (args.artist_id) { if (args.artist_id) {
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>) return fetch(baseUri + `&artist_id=${args.artist_id}`).then(
} else { (r) => r.json() as Promise<PaginatedResponse<Album>>
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>) );
} } else {
return fetch(baseUri).then(
(r) => r.json() as Promise<PaginatedResponse<Album>>
);
}
} }
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> { function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}` const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`;
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Artist>>) return fetch(baseUri).then(
(r) => r.json() as Promise<PaginatedResponse<Artist>>
);
} }
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> { function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
return fetch(`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`).then(r => r.json() as Promise<ListenActivityItem[]>) return fetch(
`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
).then((r) => r.json() as Promise<ListenActivityItem[]>);
} }
function getStats(period: string): Promise<Stats> { function getStats(period: string): Promise<Stats> {
return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>) return fetch(`/apis/web/v1/stats?period=${period}`).then(
(r) => r.json() as Promise<Stats>
);
} }
function search(q: string): Promise<SearchResponse> { function search(q: string): Promise<SearchResponse> {
q = encodeURIComponent(q) q = encodeURIComponent(q);
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>) return fetch(`/apis/web/v1/search?q=${q}`).then(
(r) => r.json() as Promise<SearchResponse>
);
} }
function imageUrl(id: string, size: string) { function imageUrl(id: string, size: string) {
if (!id) { if (!id) {
id = 'default' id = "default";
} }
return `/images/${size}/${id}` return `/images/${size}/${id}`;
} }
function replaceImage(form: FormData): Promise<Response> { function replaceImage(form: FormData): Promise<Response> {
return fetch(`/apis/web/v1/replace-image`, { return fetch(`/apis/web/v1/replace-image`, {
method: "POST", method: "POST",
body: form, body: form,
}) });
} }
function mergeTracks(from: number, to: number): Promise<Response> { function mergeTracks(from: number, to: number): Promise<Response> {
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, { return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
method: "POST", method: "POST",
}) });
} }
function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise<Response> { function mergeAlbums(
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, { from: number,
method: "POST", to: number,
}) replaceImage: boolean
): Promise<Response> {
return fetch(
`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
{
method: "POST",
}
);
}
function mergeArtists(
from: number,
to: number,
replaceImage: boolean
): Promise<Response> {
return fetch(
`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
{
method: "POST",
}
);
}
function login(
username: string,
password: string,
remember: boolean
): Promise<Response> {
const form = new URLSearchParams();
form.append("username", username);
form.append("password", password);
form.append("remember_me", String(remember));
return fetch(`/apis/web/v1/login`, {
method: "POST",
body: form,
});
} }
function mergeArtists(from: number, to: number, replaceImage: boolean): Promise<Response> { function logout(): Promise<Response> {
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, { return fetch(`/apis/web/v1/logout`, {
method: "POST", method: "POST",
}) });
} }
function login(username: string, password: string, remember: boolean): Promise<Response> {
const form = new URLSearchParams function getCfg(): Promise<Config> {
form.append('username', username) return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise<Config>);
form.append('password', password)
form.append('remember_me', String(remember))
return fetch(`/apis/web/v1/login`, {
method: "POST",
body: form,
})
} }
function logout(): Promise<Response> {
return fetch(`/apis/web/v1/logout`, { function submitListen(id: string, ts: Date): Promise<Response> {
method: "POST", const form = new URLSearchParams();
}) form.append("track_id", id);
const ms = new Date(ts).getTime();
const unix = Math.floor(ms / 1000);
form.append("unix", unix.toString());
return fetch(`/apis/web/v1/listen`, {
method: "POST",
body: form,
});
} }
function getApiKeys(): Promise<ApiKey[]> { function getApiKeys(): Promise<ApiKey[]> {
return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>) return fetch(`/apis/web/v1/user/apikeys`).then(
(r) => r.json() as Promise<ApiKey[]>
);
} }
const createApiKey = async (label: string): Promise<ApiKey> => { const createApiKey = async (label: string): Promise<ApiKey> => {
const form = new URLSearchParams const form = new URLSearchParams();
form.append('label', label) form.append("label", label);
const r = await fetch(`/apis/web/v1/user/apikeys`, { const r = await fetch(`/apis/web/v1/user/apikeys`, {
method: "POST", method: "POST",
body: form, body: form,
}); });
if (!r.ok) { if (!r.ok) {
let errorMessage = `error: ${r.status}`; let errorMessage = `error: ${r.status}`;
try { try {
const errorData: ApiError = await r.json(); const errorData: ApiError = await r.json();
if (errorData && typeof errorData.error === 'string') { if (errorData && typeof errorData.error === "string") {
errorMessage = errorData.error; errorMessage = errorData.error;
} }
} catch (e) { } catch (e) {
console.error("unexpected api error:", e); console.error("unexpected api error:", e);
}
throw new Error(errorMessage);
} }
const data: ApiKey = await r.json(); throw new Error(errorMessage);
return data; }
const data: ApiKey = await r.json();
return data;
}; };
function deleteApiKey(id: number): Promise<Response> { function deleteApiKey(id: number): Promise<Response> {
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, { return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function updateApiKeyLabel(id: number, label: string): Promise<Response> { function updateApiKeyLabel(id: number, label: string): Promise<Response> {
const form = new URLSearchParams const form = new URLSearchParams();
form.append('id', String(id)) form.append("id", String(id));
form.append('label', label) form.append("label", label);
return fetch(`/apis/web/v1/user/apikeys`, { return fetch(`/apis/web/v1/user/apikeys`, {
method: "PATCH", method: "PATCH",
body: form, body: form,
}) });
} }
function deleteItem(itemType: string, id: number): Promise<Response> { function deleteItem(itemType: string, id: number): Promise<Response> {
return fetch(`/apis/web/v1/${itemType}?id=${id}`, { return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function updateUser(username: string, password: string) { function updateUser(username: string, password: string) {
const form = new URLSearchParams const form = new URLSearchParams();
form.append('username', username) form.append("username", username);
form.append('password', password) form.append("password", password);
return fetch(`/apis/web/v1/user`, { return fetch(`/apis/web/v1/user`, {
method: "PATCH", method: "PATCH",
body: form, body: form,
}) });
} }
function getAliases(type: string, id: number): Promise<Alias[]> { function getAliases(type: string, id: number): Promise<Alias[]> {
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise<Alias[]>) return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(
} (r) => r.json() as Promise<Alias[]>
function createAlias(type: string, id: number, alias: string): Promise<Response> { );
const form = new URLSearchParams }
form.append(`${type}_id`, String(id)) function createAlias(
form.append('alias', alias) type: string,
return fetch(`/apis/web/v1/aliases`, { id: number,
method: 'POST', alias: string
body: form, ): Promise<Response> {
}) const form = new URLSearchParams();
} form.append(`${type}_id`, String(id));
function deleteAlias(type: string, id: number, alias: string): Promise<Response> { form.append("alias", alias);
const form = new URLSearchParams return fetch(`/apis/web/v1/aliases`, {
form.append(`${type}_id`, String(id)) method: "POST",
form.append('alias', alias) body: form,
return fetch(`/apis/web/v1/aliases/delete`, { });
method: "POST", }
body: form, function deleteAlias(
}) type: string,
} id: number,
function setPrimaryAlias(type: string, id: number, alias: string): Promise<Response> { alias: string
const form = new URLSearchParams ): Promise<Response> {
form.append(`${type}_id`, String(id)) const form = new URLSearchParams();
form.append('alias', alias) form.append(`${type}_id`, String(id));
return fetch(`/apis/web/v1/aliases/primary`, { form.append("alias", alias);
method: "POST", return fetch(`/apis/web/v1/aliases/delete`, {
body: form, method: "POST",
}) body: form,
});
}
function setPrimaryAlias(
type: string,
id: number,
alias: string
): Promise<Response> {
const form = new URLSearchParams();
form.append(`${type}_id`, String(id));
form.append("alias", alias);
return fetch(`/apis/web/v1/aliases/primary`, {
method: "POST",
body: form,
});
} }
function getAlbum(id: number): Promise<Album> { function getAlbum(id: number): Promise<Album> {
return fetch(`/apis/web/v1/album?id=${id}`).then(r => r.json() as Promise<Album>) return fetch(`/apis/web/v1/album?id=${id}`).then(
(r) => r.json() as Promise<Album>
);
} }
function deleteListen(listen: Listen): Promise<Response> { function deleteListen(listen: Listen): Promise<Response> {
const ms = new Date(listen.time).getTime() const ms = new Date(listen.time).getTime();
const unix= Math.floor(ms / 1000); const unix = Math.floor(ms / 1000);
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, { return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function getExport() { function getExport() {}
function getNowPlaying(): Promise<NowPlaying> {
return fetch("/apis/web/v1/now-playing").then((r) => r.json());
} }
export { export {
getLastListens, getLastListens,
getTopTracks, getTopTracks,
getTopAlbums, getTopAlbums,
getTopArtists, getTopArtists,
getActivity, getActivity,
getStats, getStats,
search, search,
replaceImage, replaceImage,
mergeTracks, mergeTracks,
mergeAlbums, mergeAlbums,
mergeArtists, mergeArtists,
imageUrl, imageUrl,
login, login,
logout, logout,
deleteItem, getCfg,
updateUser, deleteItem,
getAliases, updateUser,
createAlias, getAliases,
deleteAlias, createAlias,
setPrimaryAlias, deleteAlias,
getApiKeys, setPrimaryAlias,
createApiKey, getApiKeys,
deleteApiKey, createApiKey,
updateApiKeyLabel, deleteApiKey,
deleteListen, updateApiKeyLabel,
getAlbum, deleteListen,
getExport, getAlbum,
} getExport,
submitListen,
getNowPlaying,
};
type Track = { type Track = {
id: number id: number;
title: string title: string;
artists: SimpleArtists[] artists: SimpleArtists[];
listen_count: number listen_count: number;
image: string image: string;
album_id: number album_id: number;
musicbrainz_id: string musicbrainz_id: string;
time_listened: number time_listened: number;
} first_listen: number;
};
type Artist = { type Artist = {
id: number id: number;
name: string name: string;
image: string, image: string;
aliases: string[] aliases: string[];
listen_count: number listen_count: number;
musicbrainz_id: string musicbrainz_id: string;
time_listened: number time_listened: number;
is_primary: boolean first_listen: number;
} is_primary: boolean;
};
type Album = { type Album = {
id: number, id: number;
title: string title: string;
image: string image: string;
listen_count: number listen_count: number;
is_various_artists: boolean is_various_artists: boolean;
artists: SimpleArtists[] artists: SimpleArtists[];
musicbrainz_id: string musicbrainz_id: string;
time_listened: number time_listened: number;
} first_listen: number;
};
type Alias = { type Alias = {
id: number id: number;
alias: string alias: string;
source: string source: string;
is_primary: boolean is_primary: boolean;
} };
type Listen = { type Listen = {
time: string, time: string;
track: Track, track: Track;
} };
type PaginatedResponse<T> = { type PaginatedResponse<T> = {
items: T[], items: T[];
total_record_count: number, total_record_count: number;
has_next_page: boolean, has_next_page: boolean;
current_page: number, current_page: number;
items_per_page: number, items_per_page: number;
} };
type ListenActivityItem = { type ListenActivityItem = {
start_time: Date, start_time: Date;
listens: number listens: number;
} };
type SimpleArtists = { type SimpleArtists = {
name: string name: string;
id: number id: number;
} };
type Stats = { type Stats = {
listen_count: number listen_count: number;
track_count: number track_count: number;
album_count: number album_count: number;
artist_count: number artist_count: number;
minutes_listened: number minutes_listened: number;
} };
type SearchResponse = { type SearchResponse = {
albums: Album[] albums: Album[];
artists: Artist[] artists: Artist[];
tracks: Track[] tracks: Track[];
} };
type User = { type User = {
id: number id: number;
username: string username: string;
role: 'user' | 'admin' role: "user" | "admin";
} };
type ApiKey = { type ApiKey = {
id: number id: number;
key: string key: string;
label: string label: string;
created_at: Date created_at: Date;
} };
type ApiError = { type ApiError = {
error: string error: string;
} };
type Config = {
default_theme: string;
};
type NowPlaying = {
currently_playing: boolean;
track: Track;
};
export type { export type {
getItemsArgs, getItemsArgs,
getActivityArgs, getActivityArgs,
Track, Track,
Artist, Artist,
Album, Album,
Listen, Listen,
SearchResponse, SearchResponse,
PaginatedResponse, PaginatedResponse,
ListenActivityItem, ListenActivityItem,
User, User,
Alias, Alias,
ApiKey, ApiKey,
ApiError ApiError,
} Config,
NowPlaying,
};

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

@ -43,7 +43,8 @@ export default function ActivityOptsSelector({
useEffect(() => { useEffect(() => {
if (!disableCache) { if (!disableCache) {
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35'); // TODO: the '182' here overwrites the default range as configured in the ActivityGrid. This is bad. Only one of these should determine the default.
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '182');
if (cachedRange) rangeSetter(cachedRange); if (cachedRange) rangeSetter(cachedRange);
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]); const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
if (cachedStep) stepSetter(cachedStep); if (cachedStep) stepSetter(cachedStep);

@ -1,109 +1,150 @@
import { 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 } 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;
} }
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: [
limit: props.limit, "last-listens",
period: 'all_time', {
artist_id: props.artistId, limit: props.limit,
album_id: props.albumId, period: "all_time",
track_id: props.trackId artist_id: props.artistId,
}], album_id: props.albumId,
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), track_id: props.trackId,
}) },
],
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 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) =>
} else { (prev ?? data.items).filter((i) => i.time !== listen.time)
console.error("Failed to delete listen:", res.status) );
} } else {
} catch (err) { console.error("Failed to delete listen:", res.status);
console.error("Error deleting listen:", err) }
} } catch (err) {
console.error("Error deleting listen:", err);
} }
};
if (isPending) { if (isPending) {
return ( return (
<div className="w-[300px] sm:w-[500px]"> <div className="w-[300px] sm:w-[500px]">
<h2>Last Played</h2> <h2>Last Played</h2>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
) );
} }
if (isError) { if (isError) {
return <p className="error">Error: {error.message}</p> return <p className="error">Error: {error.message}</p>;
} }
const listens = items ?? data.items const listens = items ?? data.items;
let params = '' let params = "";
params += props.artistId ? `&artist_id=${props.artistId}` : '' params += props.artistId ? `&artist_id=${props.artistId}` : "";
params += props.albumId ? `&album_id=${props.albumId}` : '' params += props.albumId ? `&album_id=${props.albumId}` : "";
params += props.trackId ? `&track_id=${props.trackId}` : '' params += props.trackId ? `&track_id=${props.trackId}` : "";
return ( return (
<div className="text-sm sm:text-[16px]"> <div className="text-sm sm:text-[16px]">
<h2 className="hover:underline"> <h2 className="hover:underline">
<Link to={`/listens?period=all_time${params}`}>Last Played</Link> <Link to={`/listens?period=all_time${params}`}>Last Played</Link>
</h2> </h2>
<table className="-ml-4"> <table className="-ml-4">
<tbody> <tbody>
{listens.map((item) => ( {props.showNowPlaying && npData && npData.currently_playing && (
<tr key={`last_listen_${item.time}`} 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>
<button <td className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0">
onClick={() => handleDelete(item)} Now Playing
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)" </td>
aria-label="Delete" <td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
hidden={user === null || user === undefined} {props.hideArtists ? null : (
> <>
× <ArtistLinks artists={npData.track.artists} /> {" "}
</button> </>
</td> )}
<td <Link
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0" className="hover:text-[--color-fg-secondary]"
title={new Date(item.time).toString()} to={`/track/${npData.track.id}`}
> >
{timeSince(new Date(item.time))} {npData.track.title}
</td> </Link>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]"> </td>
{props.hideArtists ? null : ( </tr>
<> )}
<ArtistLinks artists={item.track.artists} /> {' '} {listens.map((item) => (
</> <tr
)} key={`last_listen_${item.time}`}
<Link className="group hover:bg-[--color-bg-secondary]"
className="hover:text-[--color-fg-secondary]" >
to={`/track/${item.track.id}`} <td className="w-[18px] pr-2 align-middle">
> <button
{item.track.title} onClick={() => handleDelete(item)}
</Link> className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
</td> aria-label="Delete"
</tr> hidden={user === null || user === undefined}
))} >
</tbody> ×
</table> </button>
</div> </td>
) <td
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
title={new Date(item.time).toString()}
>
{timeSince(new Date(item.time))}
</td>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
{props.hideArtists ? null : (
<>
<ArtistLinks artists={item.track.artists} /> {" "}
</>
)}
<Link
className="hover:text-[--color-fg-secondary]"
to={`/track/${item.track.id}`}
>
{item.track.title}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
} }

@ -0,0 +1,57 @@
import { useState } from "react";
import { Modal } from "./Modal";
import { AsyncButton } from "../AsyncButton";
import { submitListen } from "api/api";
import { useNavigate } from "react-router";
interface Props {
open: boolean
setOpen: Function
trackid: number
}
export default function AddListenModal({ open, setOpen, trackid }: Props) {
const [ts, setTS] = useState<Date>(new Date);
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate()
const close = () => {
setOpen(false)
}
const submit = () => {
setLoading(true)
submitListen(trackid.toString(), ts)
.then(r => {
if(r.ok) {
setLoading(false)
navigate(0)
} else {
r.json().then(r => setError(r.error))
setLoading(false)
}
})
}
const formatForDatetimeLocal = (d: Date) => {
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
return (
<Modal isOpen={open} onClose={close}>
<h2>Add Listen</h2>
<div className="flex flex-col items-center gap-4">
<input
type="datetime-local"
className="w-full mx-auto fg bg rounded p-2"
value={formatForDatetimeLocal(ts)}
onChange={(e) => setTS(new Date(e.target.value))}
/>
<AsyncButton loading={loading} onClick={submit}>Submit</AsyncButton>
<p className="error">{error}</p>
</div>
</Modal>
)
}

@ -1,19 +1,20 @@
import type { Theme } from "~/providers/ThemeProvider"; import type { Theme } from "~/styles/themes.css";
interface Props { interface Props {
theme: Theme theme: Theme
themeName: string
setTheme: Function setTheme: Function
} }
export default function ThemeOption({ theme, setTheme }: Props) { export default function ThemeOption({ theme, themeName, setTheme }: Props) {
const capitalizeFirstLetter = (s: string) => { const capitalizeFirstLetter = (s: string) => {
return s.charAt(0).toUpperCase() + s.slice(1); return s.charAt(0).toUpperCase() + s.slice(1);
} }
return ( return (
<div onClick={() => setTheme(theme.name)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}> <div onClick={() => setTheme(themeName)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}>
<div className="text-xs sm:text-sm">{capitalizeFirstLetter(theme.name)}</div> <div className="text-xs sm:text-sm">{capitalizeFirstLetter(themeName)}</div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div> <div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div> <div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div> <div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>

@ -1,69 +1,78 @@
// ThemeSwitcher.tsx import { useState } from "react";
import { useEffect, useState } from 'react'; import { useTheme } from "../../hooks/useTheme";
import { useTheme } from '../../hooks/useTheme'; import themes from "~/styles/themes.css";
import themes from '~/styles/themes.css'; import ThemeOption from "./ThemeOption";
import ThemeOption from './ThemeOption'; import { AsyncButton } from "../AsyncButton";
import { AsyncButton } from '../AsyncButton';
export function ThemeSwitcher() { export function ThemeSwitcher() {
const { theme, setTheme } = useTheme(); const { setTheme } = useTheme();
const initialTheme = { const initialTheme = {
bg: "#1e1816", bg: "#1e1816",
bgSecondary: "#2f2623", bgSecondary: "#2f2623",
bgTertiary: "#453733", bgTertiary: "#453733",
fg: "#f8f3ec", fg: "#f8f3ec",
fgSecondary: "#d6ccc2", fgSecondary: "#d6ccc2",
fgTertiary: "#b4a89c", fgTertiary: "#b4a89c",
primary: "#f5a97f", primary: "#f5a97f",
primaryDim: "#d88b65", primaryDim: "#d88b65",
accent: "#f9db6d", accent: "#f9db6d",
accentDim: "#d9bc55", accentDim: "#d9bc55",
error: "#e26c6a", error: "#e26c6a",
warning: "#f5b851", warning: "#f5b851",
success: "#8fc48f", success: "#8fc48f",
info: "#87b8dd", info: "#87b8dd",
} };
const { setCustomTheme, getCustomTheme } = useTheme()
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " "))
const handleCustomTheme = () => {
console.log(custom)
try {
const theme = JSON.parse(custom)
theme.name = "custom"
setCustomTheme(theme)
delete theme.name
setCustom(JSON.stringify(theme, null, " "))
console.log(theme)
} catch(err) {
console.log(err)
}
}
useEffect(() => { const { setCustomTheme, getCustomTheme, resetTheme } = useTheme();
if (theme) { const [custom, setCustom] = useState(
setTheme(theme) JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")
} );
}, [theme]);
return ( const handleCustomTheme = () => {
<div className='flex flex-col gap-10'> console.log(custom);
<div> try {
<h2>Select Theme</h2> const themeData = JSON.parse(custom);
<div className="grid grid-cols-2 items-center gap-2"> setCustomTheme(themeData);
{themes.map((t) => ( setCustom(JSON.stringify(themeData, null, " "));
<ThemeOption setTheme={setTheme} key={t.name} theme={t} /> console.log(themeData);
))} } catch (err) {
</div> console.log(err);
</div> }
<div> };
<h2>Use Custom Theme</h2>
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg"> return (
<textarea name="custom-theme" onChange={(e) => setCustom(e.target.value)} id="custom-theme-input" className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md" value={custom} /> <div className="flex flex-col gap-10">
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton> <div>
</div> <div className="flex items-center gap-3">
</div> <h2>Select Theme</h2>
<div className="mb-3">
<AsyncButton onClick={resetTheme}>Reset</AsyncButton>
</div>
</div>
<div className="grid grid-cols-2 items-center gap-2">
{Object.entries(themes).map(([name, themeData]) => (
<ThemeOption
setTheme={setTheme}
key={name}
theme={themeData}
themeName={name}
/>
))}
</div>
</div>
<div>
<h2>Use Custom Theme</h2>
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
<textarea
name="custom-theme"
onChange={(e) => setCustom(e.target.value)}
id="custom-theme-input"
className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md"
value={custom}
/>
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
</div> </div>
); </div>
</div>
);
} }

@ -1,10 +1,11 @@
import type { User } from "api/api"; import { getCfg, type User } from "api/api";
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
interface AppContextType { interface AppContextType {
user: User | null | undefined; user: User | null | undefined;
configurableHomeActivity: boolean; configurableHomeActivity: boolean;
homeItems: number; homeItems: number;
defaultTheme: string;
setConfigurableHomeActivity: (value: boolean) => void; setConfigurableHomeActivity: (value: boolean) => void;
setHomeItems: (value: number) => void; setHomeItems: (value: number) => void;
setUsername: (value: string) => void; setUsername: (value: string) => void;
@ -22,15 +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 [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false); const [defaultTheme, setDefaultTheme] = useState<string | undefined>(
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")
@ -42,9 +47,19 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
setConfigurableHomeActivity(true); setConfigurableHomeActivity(true);
setHomeItems(12); setHomeItems(12);
getCfg().then((cfg) => {
console.log(cfg);
if (cfg.default_theme !== "") {
setDefaultTheme(cfg.default_theme);
} else {
setDefaultTheme("yuu");
}
});
}, []); }, []);
if (user === undefined) { // Block rendering the app until config is loaded
if (user === undefined || defaultTheme === undefined) {
return null; return null;
} }
@ -52,10 +67,13 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
user, user,
configurableHomeActivity, configurableHomeActivity,
homeItems, homeItems,
defaultTheme,
setConfigurableHomeActivity, setConfigurableHomeActivity,
setHomeItems, setHomeItems,
setUsername, setUsername,
}; };
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>; return (
}; <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>
);
};

@ -1,95 +1,131 @@
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react'; import {
import { type Theme } from '~/styles/themes.css'; createContext,
import { themeVars } from '~/styles/vars.css'; useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
import { type Theme, themes } from "~/styles/themes.css";
import { themeVars } from "~/styles/vars.css";
import { useAppContext } from "./AppProvider";
interface ThemeContextValue { interface ThemeContextValue {
theme: string; themeName: string;
setTheme: (theme: string) => void; theme: Theme;
setCustomTheme: (theme: Theme) => void; setTheme: (theme: string) => void;
getCustomTheme: () => Theme | undefined; resetTheme: () => void;
setCustomTheme: (theme: Theme) => void;
getCustomTheme: () => Theme | undefined;
} }
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined); const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function toKebabCase(str: string) { function toKebabCase(str: string) {
return str.replace(/[A-Z]/g, m => '-' + m.toLowerCase()); return str.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
} }
function applyCustomThemeVars(theme: Theme) { function applyCustomThemeVars(theme: Theme) {
const root = document.documentElement; const root = document.documentElement;
for (const [key, value] of Object.entries(theme)) { for (const [key, value] of Object.entries(theme)) {
if (key === 'name') continue; if (key === "name") continue;
root.style.setProperty(`--color-${toKebabCase(key)}`, value); root.style.setProperty(`--color-${toKebabCase(key)}`, value);
} }
} }
function clearCustomThemeVars() { function clearCustomThemeVars() {
for (const cssVar of Object.values(themeVars)) { for (const cssVar of Object.values(themeVars)) {
document.documentElement.style.removeProperty(cssVar); document.documentElement.style.removeProperty(cssVar);
} }
} }
export function ThemeProvider({ function getStoredCustomTheme(): Theme | undefined {
theme: initialTheme, const themeStr = localStorage.getItem("custom-theme");
children, if (!themeStr) return undefined;
}: { try {
theme: string; const parsed = JSON.parse(themeStr);
children: ReactNode; const { name, ...theme } = parsed;
}) { return theme as Theme;
const [theme, setThemeName] = useState(initialTheme); } catch {
return undefined;
const setTheme = (theme: string) => { }
setThemeName(theme) }
export function ThemeProvider({ children }: { children: ReactNode }) {
let defaultTheme = useAppContext().defaultTheme;
let initialTheme = localStorage.getItem("theme") ?? defaultTheme;
const [themeName, setThemeName] = useState(initialTheme);
const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
if (initialTheme === "custom") {
const customTheme = getStoredCustomTheme();
return customTheme || themes[defaultTheme];
}
return themes[initialTheme] || themes[defaultTheme];
});
const setTheme = (newThemeName: string) => {
setThemeName(newThemeName);
if (newThemeName === "custom") {
const customTheme = getStoredCustomTheme();
if (customTheme) {
setCurrentTheme(customTheme);
} else {
// Fallback to default theme if no custom theme found
setThemeName(defaultTheme);
setCurrentTheme(themes[defaultTheme]);
}
} else {
const foundTheme = themes[newThemeName];
if (foundTheme) {
localStorage.setItem("theme", newThemeName);
setCurrentTheme(foundTheme);
}
} }
};
const resetTheme = () => {
setThemeName(defaultTheme);
localStorage.removeItem("theme");
setCurrentTheme(themes[defaultTheme]);
};
const setCustomTheme = useCallback((customTheme: Theme) => {
localStorage.setItem("custom-theme", JSON.stringify(customTheme));
applyCustomThemeVars(customTheme);
setThemeName("custom");
localStorage.setItem("theme", "custom");
setCurrentTheme(customTheme);
}, []);
const getCustomTheme = (): Theme | undefined => {
return getStoredCustomTheme();
};
useEffect(() => {
const root = document.documentElement;
root.setAttribute("data-theme", themeName);
const setCustomTheme = useCallback((customTheme: Theme) => { if (themeName === "custom") {
localStorage.setItem('custom-theme', JSON.stringify(customTheme)); applyCustomThemeVars(currentTheme);
applyCustomThemeVars(customTheme); } else {
setTheme('custom'); clearCustomThemeVars();
}, []);
const getCustomTheme = (): Theme | undefined => {
const themeStr = localStorage.getItem('custom-theme');
if (!themeStr) {
return undefined
}
try {
let theme = JSON.parse(themeStr) as Theme
return theme
} catch (err) {
return undefined
}
} }
}, [themeName, currentTheme]);
useEffect(() => { return (
const root = document.documentElement; <ThemeContext.Provider
value={{
root.setAttribute('data-theme', theme); themeName,
localStorage.setItem('theme', theme) theme: currentTheme,
console.log(theme) setTheme,
resetTheme,
if (theme === 'custom') { setCustomTheme,
const saved = localStorage.getItem('custom-theme'); getCustomTheme,
if (saved) { }}
try { >
const parsed = JSON.parse(saved) as Theme; {children}
applyCustomThemeVars(parsed); </ThemeContext.Provider>
} catch (err) { );
console.error('Invalid custom theme in localStorage', err);
}
} else {
setTheme('yuu')
}
} else {
clearCustomThemeVars()
}
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, setCustomTheme, getCustomTheme }}>
{children}
</ThemeContext.Provider>
);
} }
export { ThemeContext }; export { ThemeContext };

@ -58,12 +58,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
} }
export default function App() { export default function App() {
let theme = localStorage.getItem('theme') ?? 'yuu'
return ( return (
<> <>
<AppProvider> <AppProvider>
<ThemeProvider theme={theme}> <ThemeProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<div className="flex-col flex sm:flex-row"> <div className="flex-col flex sm:flex-row">
<Sidebar /> <Sidebar />
@ -99,18 +97,12 @@ export function ErrorBoundary() {
stack = error.stack; stack = error.stack;
} }
let theme = 'yuu'
try {
theme = localStorage.getItem('theme') ?? theme
} catch(err) {
console.log(err)
}
const title = `${message} - Koito` const title = `${message} - Koito`
return ( return (
<AppProvider> <AppProvider>
<ThemeProvider theme={theme}> <ThemeProvider>
<title>{title}</title> <title>{title}</title>
<div className="flex"> <div className="flex">
<Sidebar /> <Sidebar />

@ -33,7 +33,7 @@ export default function Home() {
<TopArtists period={period} limit={homeItems} /> <TopArtists period={period} limit={homeItems} />
<TopAlbums period={period} limit={homeItems} /> <TopAlbums period={period} limit={homeItems} />
<TopTracks period={period} limit={homeItems} /> <TopTracks period={period} limit={homeItems} />
<LastPlays limit={Math.floor(homeItems * 2.7)} /> <LastPlays showNowPlaying={true} limit={Math.floor(homeItems * 2.7)} />
</div> </div>
</div> </div>
</main> </main>

@ -44,6 +44,7 @@ export default function Album() {
subContent={<div className="flex flex-col gap-2 items-start"> subContent={<div className="flex flex-col gap-2 items-start">
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>} {album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
{<p title={Math.floor(album.time_listened / 60) + " minutes"}>{timeListenedString(album.time_listened)}</p>} {<p title={Math.floor(album.time_listened / 60) + " minutes"}>{timeListenedString(album.time_listened)}</p>}
{<p title={new Date(album.first_listen * 1000).toLocaleString()}>Listening since {new Date(album.first_listen * 1000).toLocaleDateString()}</p>}
</div>} </div>}
> >
<div className="mt-10"> <div className="mt-10">

@ -50,6 +50,7 @@ export default function Artist() {
subContent={<div className="flex flex-col gap-2 items-start"> subContent={<div className="flex flex-col gap-2 items-start">
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>} {artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
{<p title={Math.floor(artist.time_listened / 60) + " minutes"}>{timeListenedString(artist.time_listened)}</p>} {<p title={Math.floor(artist.time_listened / 60) + " minutes"}>{timeListenedString(artist.time_listened)}</p>}
{<p title={new Date(artist.first_listen * 1000).toLocaleString()}>Listening since {new Date(artist.first_listen * 1000).toLocaleDateString()}</p>}
</div>} </div>}
> >
<div className="mt-10"> <div className="mt-10">

@ -2,13 +2,14 @@ import React, { useEffect, useState } from "react";
import { average } from "color.js"; import { average } from "color.js";
import { imageUrl, type SearchResponse } from "api/api"; import { imageUrl, type SearchResponse } from "api/api";
import ImageDropHandler from "~/components/ImageDropHandler"; import ImageDropHandler from "~/components/ImageDropHandler";
import { Edit, ImageIcon, Merge, Trash } from "lucide-react"; import { Edit, ImageIcon, Merge, Plus, Trash } from "lucide-react";
import { useAppContext } from "~/providers/AppProvider"; import { useAppContext } from "~/providers/AppProvider";
import MergeModal from "~/components/modals/MergeModal"; import MergeModal from "~/components/modals/MergeModal";
import ImageReplaceModal from "~/components/modals/ImageReplaceModal"; import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
import DeleteModal from "~/components/modals/DeleteModal"; import DeleteModal from "~/components/modals/DeleteModal";
import RenameModal from "~/components/modals/EditModal/EditModal"; import RenameModal from "~/components/modals/EditModal/EditModal";
import EditModal from "~/components/modals/EditModal/EditModal"; import EditModal from "~/components/modals/EditModal/EditModal";
import AddListenModal from "~/components/modals/AddListenModal";
export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise<Response> export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise<Response>
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
@ -32,6 +33,7 @@ export default function MediaLayout(props: Props) {
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [imageModalOpen, setImageModalOpen] = useState(false); const [imageModalOpen, setImageModalOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false); const [renameModalOpen, setRenameModalOpen] = useState(false);
const [addListenModalOpen, setAddListenModalOpen] = useState(false);
const { user } = useAppContext(); const { user } = useAppContext();
useEffect(() => { useEffect(() => {
@ -80,6 +82,12 @@ export default function MediaLayout(props: Props) {
</div> </div>
{ user && { user &&
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center"> <div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center">
{ props.type === "Track" &&
<>
<button title="Add Listen" className="hover:cursor-pointer" onClick={() => setAddListenModalOpen(true)}><Plus size={iconSize} /></button>
<AddListenModal open={addListenModalOpen} setOpen={setAddListenModalOpen} trackid={props.id} />
</>
}
<button title="Edit Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={iconSize} /></button> <button title="Edit Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={iconSize} /></button>
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={iconSize} /></button> <button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={iconSize} /></button>
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={iconSize} /></button> <button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={iconSize} /></button>

@ -46,7 +46,8 @@ export default function Track() {
subContent={<div className="flex flex-col gap-2 items-start"> subContent={<div className="flex flex-col gap-2 items-start">
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link> <Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>} {track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
{<p title={Math.floor(track.time_listened / 60) + " minutes"}>{timeListenedString(track.time_listened)}</p>} {<p title={Math.floor(track.time_listened / 60) + " minutes"}>{timeListenedString(track.time_listened)}</p>}
{<p title={new Date(track.first_listen * 1000).toLocaleString()}>Listening since {new Date(track.first_listen * 1000).toLocaleDateString()}</p>}
</div>} </div>}
> >
<div className="mt-10"> <div className="mt-10">

@ -12,7 +12,6 @@ import { themes, type Theme } from "~/styles/themes.css"
export default function ThemeHelper() { export default function ThemeHelper() {
const initialTheme = { const initialTheme = {
name: "custom",
bg: "#1e1816", bg: "#1e1816",
bgSecondary: "#2f2623", bgSecondary: "#2f2623",
bgTertiary: "#453733", bgTertiary: "#453733",
@ -36,9 +35,6 @@ export default function ThemeHelper() {
console.log(custom) console.log(custom)
try { try {
const theme = JSON.parse(custom) as Theme const theme = JSON.parse(custom) as Theme
if (theme.name !== "custom") {
throw new Error("theme name must be 'custom'")
}
console.log(theme) console.log(theme)
setCustomTheme(theme) setCustomTheme(theme)
} catch(err) { } catch(err) {

@ -2,11 +2,10 @@ import { globalStyle } from "@vanilla-extract/css"
import { themeVars } from "./vars.css" import { themeVars } from "./vars.css"
export type Theme = { export type Theme = {
name: string, bg: string
bg: string bgSecondary: string
bgSecondary: string
bgTertiary: string bgTertiary: string
fg: string fg: string
fgSecondary: string fgSecondary: string
fgTertiary: string fgTertiary: string
primary: string primary: string
@ -23,9 +22,8 @@ export const THEME_KEYS = [
'--color' '--color'
] ]
export const themes: Theme[] = [ export const themes: Record<string, Theme> = {
{ yuu: {
name: "yuu",
bg: "#1e1816", bg: "#1e1816",
bgSecondary: "#2f2623", bgSecondary: "#2f2623",
bgTertiary: "#453733", bgTertiary: "#453733",
@ -41,8 +39,7 @@ export const themes: Theme[] = [
success: "#8fc48f", success: "#8fc48f",
info: "#87b8dd", info: "#87b8dd",
}, },
{ varia: {
name: "varia",
bg: "rgb(25, 25, 29)", bg: "rgb(25, 25, 29)",
bgSecondary: "#222222", bgSecondary: "#222222",
bgTertiary: "#333333", bgTertiary: "#333333",
@ -58,8 +55,7 @@ export const themes: Theme[] = [
success: "#4caf50", success: "#4caf50",
info: "#2196f3", info: "#2196f3",
}, },
{ midnight: {
name: "midnight",
bg: "rgb(8, 15, 24)", bg: "rgb(8, 15, 24)",
bgSecondary: "rgb(15, 27, 46)", bgSecondary: "rgb(15, 27, 46)",
bgTertiary: "rgb(15, 41, 70)", bgTertiary: "rgb(15, 41, 70)",
@ -75,8 +71,7 @@ export const themes: Theme[] = [
success: "#4caf50", success: "#4caf50",
info: "#2196f3", info: "#2196f3",
}, },
{ catppuccin: {
name: "catppuccin",
bg: "#1e1e2e", bg: "#1e1e2e",
bgSecondary: "#181825", bgSecondary: "#181825",
bgTertiary: "#11111b", bgTertiary: "#11111b",
@ -92,8 +87,7 @@ export const themes: Theme[] = [
success: "#a6e3a1", success: "#a6e3a1",
info: "#89dceb", info: "#89dceb",
}, },
{ autumn: {
name: "autumn",
bg: "rgb(44, 25, 18)", bg: "rgb(44, 25, 18)",
bgSecondary: "rgb(70, 40, 18)", bgSecondary: "rgb(70, 40, 18)",
bgTertiary: "#4b2f1c", bgTertiary: "#4b2f1c",
@ -109,8 +103,7 @@ export const themes: Theme[] = [
success: "#6b8e23", success: "#6b8e23",
info: "#c084fc", info: "#c084fc",
}, },
{ black: {
name: "black",
bg: "#000000", bg: "#000000",
bgSecondary: "#1a1a1a", bgSecondary: "#1a1a1a",
bgTertiary: "#2a2a2a", bgTertiary: "#2a2a2a",
@ -126,8 +119,7 @@ export const themes: Theme[] = [
success: "#4caf50", success: "#4caf50",
info: "#2196f3", info: "#2196f3",
}, },
{ wine: {
name: "wine",
bg: "#23181E", bg: "#23181E",
bgSecondary: "#2C1C25", bgSecondary: "#2C1C25",
bgTertiary: "#422A37", bgTertiary: "#422A37",
@ -143,97 +135,92 @@ export const themes: Theme[] = [
success: "#bbf7d0", success: "#bbf7d0",
info: "#bae6fd", info: "#bae6fd",
}, },
{ pearl: {
name: "pearl", bg: "#FFFFFF",
bg: "#FFFFFF", bgSecondary: "#EEEEEE",
bgSecondary: "#EEEEEE", bgTertiary: "#E0E0E0",
bgTertiary: "#E0E0E0", fg: "#333333",
fg: "#333333", fgSecondary: "#555555",
fgSecondary: "#555555",
fgTertiary: "#777777", fgTertiary: "#777777",
primary: "#007BFF", primary: "#007BFF",
primaryDim: "#0056B3", primaryDim: "#0056B3",
accent: "#28A745", accent: "#28A745",
accentDim: "#1E7E34", accentDim: "#1E7E34",
error: "#DC3545", error: "#DC3545",
warning: "#FFC107", warning: "#FFC107",
success: "#28A745", success: "#28A745",
info: "#17A2B8", info: "#17A2B8",
}, },
{ asuka: {
name: "asuka", bg: "#3B1212",
bg: "#3B1212", bgSecondary: "#471B1B",
bgSecondary: "#471B1B", bgTertiary: "#020202",
bgTertiary: "#020202", fg: "#F1E9E6",
fg: "#F1E9E6", fgSecondary: "#CCB6AE",
fgSecondary: "#CCB6AE",
fgTertiary: "#9F8176", fgTertiary: "#9F8176",
primary: "#F1E9E6", primary: "#F1E9E6",
primaryDim: "#CCB6AE", primaryDim: "#CCB6AE",
accent: "#41CE41", accent: "#41CE41",
accentDim: "#3BA03B", accentDim: "#3BA03B",
error: "#DC143C", error: "#DC143C",
warning: "#FFD700", warning: "#FFD700",
success: "#32CD32", success: "#32CD32",
info: "#1E90FF", info: "#1E90FF",
}, },
{ urim: {
name: "urim", bg: "#101713",
bg: "#101713", bgSecondary: "#1B2921",
bgSecondary: "#1B2921", bgTertiary: "#273B30",
bgTertiary: "#273B30", fg: "#D2E79E",
fg: "#D2E79E", fgSecondary: "#B4DA55",
fgSecondary: "#B4DA55",
fgTertiary: "#7E9F2A", fgTertiary: "#7E9F2A",
primary: "#ead500", primary: "#ead500",
primaryDim: "#C1B210", primaryDim: "#C1B210",
accent: "#28A745", accent: "#28A745",
accentDim: "#1E7E34", accentDim: "#1E7E34",
error: "#EE5237", error: "#EE5237",
warning: "#FFC107", warning: "#FFC107",
success: "#28A745", success: "#28A745",
info: "#17A2B8", info: "#17A2B8",
}, },
{ match: {
name: "match", bg: "#071014",
bg: "#071014", bgSecondary: "#0A181E",
bgSecondary: "#0A181E", bgTertiary: "#112A34",
bgTertiary: "#112A34", fg: "#ebeaeb",
fg: "#ebeaeb", fgSecondary: "#BDBDBD",
fgSecondary: "#BDBDBD",
fgTertiary: "#A2A2A2", fgTertiary: "#A2A2A2",
primary: "#fda827", primary: "#fda827",
primaryDim: "#C78420", primaryDim: "#C78420",
accent: "#277CFD", accent: "#277CFD",
accentDim: "#1F60C1", accentDim: "#1F60C1",
error: "#F14426", error: "#F14426",
warning: "#FFC107", warning: "#FFC107",
success: "#28A745", success: "#28A745",
info: "#17A2B8", info: "#17A2B8",
}, },
{ lemon: {
name: "lemon", bg: "#1a171a",
bg: "#1a171a", bgSecondary: "#2E272E",
bgSecondary: "#2E272E", bgTertiary: "#443844",
bgTertiary: "#443844", fg: "#E6E2DC",
fg: "#E6E2DC", fgSecondary: "#B2ACA1",
fgSecondary: "#B2ACA1",
fgTertiary: "#968F82", fgTertiary: "#968F82",
primary: "#f5c737", primary: "#f5c737",
primaryDim: "#C29D2F", primaryDim: "#C29D2F",
accent: "#277CFD", accent: "#277CFD",
accentDim: "#1F60C1", accentDim: "#1F60C1",
error: "#F14426", error: "#F14426",
warning: "#FFC107", warning: "#FFC107",
success: "#28A745", success: "#28A745",
info: "#17A2B8", info: "#17A2B8",
} }
]; };
export default themes export default themes
themes.forEach((theme) => { Object.entries(themes).forEach(([name, theme]) => {
const selector = `[data-theme="${theme.name}"]` const selector = `[data-theme="${name}"]`
globalStyle(selector, { globalStyle(selector, {
vars: { vars: {

@ -1,6 +1,6 @@
{ {
"name": "MyWebSite", "name": "Koito",
"short_name": "MySite", "short_name": "Koito",
"icons": [ "icons": [
{ {
"src": "/web-app-manifest-192x192.png", "src": "/web-app-manifest-192x192.png",

@ -29,6 +29,16 @@ WHERE at.artist_id = $5
ORDER BY l.listened_at DESC ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4; LIMIT $3 OFFSET $4;
-- name: GetFirstListenFromArtist :one
SELECT
l.*
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
JOIN artist_tracks at ON t.id = at.track_id
WHERE at.artist_id = $1
ORDER BY l.listened_at ASC
LIMIT 1;
-- name: GetLastListensFromReleasePaginated :many -- name: GetLastListensFromReleasePaginated :many
SELECT SELECT
l.*, l.*,
@ -42,6 +52,15 @@ WHERE l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4; LIMIT $3 OFFSET $4;
-- name: GetFirstListenFromRelease :one
SELECT
l.*
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.release_id = $1
ORDER BY l.listened_at ASC
LIMIT 1;
-- name: GetLastListensFromTrackPaginated :many -- name: GetLastListensFromTrackPaginated :many
SELECT SELECT
l.*, l.*,
@ -55,6 +74,15 @@ WHERE l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4; LIMIT $3 OFFSET $4;
-- name: GetFirstListenFromTrack :one
SELECT
l.*
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.id = $1
ORDER BY l.listened_at ASC
LIMIT 1;
-- name: CountListens :one -- name: CountListens :one
SELECT COUNT(*) AS total_count SELECT COUNT(*) AS total_count
FROM listens l FROM listens l

@ -74,8 +74,8 @@ JOIN artist_releases ar ON r.id = ar.release_id
WHERE ar.artist_id = $1; WHERE ar.artist_id = $1;
-- name: AssociateArtistToRelease :exec -- name: AssociateArtistToRelease :exec
INSERT INTO artist_releases (artist_id, release_id) INSERT INTO artist_releases (artist_id, release_id, is_primary)
VALUES ($1, $2) VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- name: GetReleasesWithoutImages :many -- name: GetReleasesWithoutImages :many

@ -4,8 +4,8 @@ VALUES ($1, $2, $3)
RETURNING *; RETURNING *;
-- name: AssociateArtistToTrack :exec -- name: AssociateArtistToTrack :exec
INSERT INTO artist_tracks (artist_id, track_id) INSERT INTO artist_tracks (artist_id, track_id, is_primary)
VALUES ($1, $2) VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- name: GetTrack :one -- name: GetTrack :one

@ -23,6 +23,9 @@ If the environment variable is defined without **and** with the suffix at the sa
##### KOITO_DEFAULT_PASSWORD ##### KOITO_DEFAULT_PASSWORD
- Default: `changeme` - Default: `changeme`
- Description: The password for the user that is created on first startup. Only applies when running Koito for the first time. - Description: The password for the user that is created on first startup. Only applies when running Koito for the first time.
##### KOITO_DEFAULT_THEME
- Default: `yuu`
- Description: The lowercase name of the default theme to be used by the client. Overridden if a user picks a theme in the theme switcher.
##### KOITO_BIND_ADDR ##### KOITO_BIND_ADDR
- Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`. - Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`.
##### KOITO_LISTEN_PORT ##### KOITO_LISTEN_PORT
@ -37,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.
@ -63,6 +69,12 @@ If the environment variable is defined without **and** with the suffix at the sa
- Description: Disables Cover Art Archive as a source for finding album images. - Description: Disables Cover Art Archive as a source for finding album images.
##### KOITO_DISABLE_MUSICBRAINZ ##### KOITO_DISABLE_MUSICBRAINZ
- Default: `false` - Default: `false`
##### KOITO_SUBSONIC_URL
- Required: `true` if KOITO_SUBSONIC_PARAMS is set
- Description: The URL of your subsonic compatible music server. For example, `https://navidrome.mydomain.com`.
##### KOITO_SUBSONIC_PARAMS
- Required: `true` if KOITO_SUBSONIC_URL is set
- Description: The `u`, `t`, and `s` authentication parameters to use for authenticated requests to your subsonic server, in the format `u=XXX&t=XXX&s=XXX`. An easy way to find them is to open the network tab in the developer tools of your browser of choice and copy them from a request.
##### KOITO_SKIP_IMPORT ##### KOITO_SKIP_IMPORT
- Default: `false` - Default: `false`
- Description: Skips running the importer on startup. - Description: Skips running the importer on startup.
@ -81,4 +93,4 @@ If the environment variable is defined without **and** with the suffix at the sa
- Description: When true, images will be downloaded and cached during imports. - Description: When true, images will be downloaded and cached during imports.
##### KOITO_CORS_ALLOWED_ORIGINS ##### KOITO_CORS_ALLOWED_ORIGINS
- Default: No CORS policy - Default: No CORS policy
- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins. - Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins.

@ -107,9 +107,10 @@ func Run(
l.Debug().Msg("Engine: Initializing image sources") l.Debug().Msg("Engine: Initializing image sources")
images.Initialize(images.ImageSourceOpts{ images.Initialize(images.ImageSourceOpts{
UserAgent: cfg.UserAgent(), UserAgent: cfg.UserAgent(),
EnableCAA: !cfg.CoverArtArchiveDisabled(), EnableCAA: !cfg.CoverArtArchiveDisabled(),
EnableDeezer: !cfg.DeezerDisabled(), EnableDeezer: !cfg.DeezerDisabled(),
EnableSubsonic: cfg.SubsonicEnabled(),
}) })
l.Info().Msg("Engine: Image sources initialized") l.Info().Msg("Engine: Image sources initialized")

@ -211,10 +211,8 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
Time: listenedAt, Time: listenedAt,
UserID: u.ID, UserID: u.ID,
Client: client, Client: client,
} IsNowPlaying: req.ListenType == ListenTypePlayingNow,
SkipSaveListen: req.ListenType == ListenTypePlayingNow,
if req.ListenType == ListenTypePlayingNow {
opts.SkipSaveListen = true
} }
_, err, shared := sfGroup.Do(buildCaolescingKey(payload), func() (interface{}, error) { _, err, shared := sfGroup.Do(buildCaolescingKey(payload), func() (interface{}, error) {

@ -0,0 +1,77 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/gabehf/koito/engine/middleware"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
)
func SubmitListenWithIDHandler(store db.DB) http.HandlerFunc {
var defaultClientStr = "Koito Web UI"
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
l.Debug().Msg("SubmitListenWithIDHandler: Got request")
u := middleware.GetUserFromContext(ctx)
if u == nil {
l.Debug().Msg("SubmitListenWithIDHandler: Unauthorized request (user context is nil)")
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return
}
err := r.ParseForm()
if err != nil {
l.Debug().Msg("SubmitListenWithIDHandler: Failed to parse form")
utils.WriteError(w, "form is invalid", http.StatusBadRequest)
return
}
trackIDStr := r.FormValue("track_id")
timestampStr := r.FormValue("unix")
client := r.FormValue("client")
if client == "" {
client = defaultClientStr
}
if trackIDStr == "" || timestampStr == "" {
l.Debug().Msg("SubmitListenWithIDHandler: Request is missing required parameters")
utils.WriteError(w, "track_id and unix (timestamp) must be provided", http.StatusBadRequest)
return
}
trackID, err := strconv.Atoi(trackIDStr)
if err != nil {
l.Debug().AnErr("error", err).Msg("SubmitListenWithIDHandler: Invalid track id")
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
return
}
unix, err := strconv.ParseInt(timestampStr, 10, 64)
if err != nil || time.Now().Unix() < unix {
l.Debug().AnErr("error", err).Msg("SubmitListenWithIDHandler: Invalid unix timestamp")
utils.WriteError(w, "invalid timestamp", http.StatusBadRequest)
return
}
ts := time.Unix(unix, 0)
err = store.SaveListen(ctx, db.SaveListenOpts{
TrackID: int32(trackID),
Time: ts,
UserID: u.ID,
Client: client,
})
if err != nil {
l.Err(err).Msg("SubmitListenWithIDHandler: Failed to submit listen")
utils.WriteError(w, "failed to submit listen", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
}

@ -0,0 +1,41 @@
package handlers
import (
"net/http"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/memkv"
"github.com/gabehf/koito/internal/models"
"github.com/gabehf/koito/internal/utils"
)
type NowPlayingResponse struct {
CurrentlyPlaying bool `json:"currently_playing"`
Track models.Track `json:"track"`
}
func NowPlayingHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
l.Debug().Msg("NowPlayingHandler: Got request")
// Hardcoded user id as 1. Not great but it works until (if) multi-user is supported.
if trackIdI, ok := memkv.Store.Get("1"); !ok {
utils.WriteJSON(w, http.StatusOK, NowPlayingResponse{CurrentlyPlaying: false})
} else if trackId, ok := trackIdI.(int32); !ok {
l.Debug().Msg("NowPlayingHandler: Failed type assertion for trackIdI")
utils.WriteError(w, "internal server error", http.StatusInternalServerError)
} else {
track, err := store.GetTrack(ctx, db.GetTrackOpts{ID: trackId})
if err != nil {
l.Error().Err(err).Msg("NowPlayingHandler: Failed to get track from database")
utils.WriteError(w, "failed to fetch currently playing track from database", http.StatusInternalServerError)
} else {
utils.WriteJSON(w, http.StatusOK, NowPlayingResponse{CurrentlyPlaying: true, Track: *track})
}
}
}
}

@ -0,0 +1,18 @@
package handlers
import (
"net/http"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/utils"
)
type ServerConfig struct {
DefaultTheme string `json:"default_theme"`
}
func GetCfgHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
utils.WriteJSON(w, http.StatusOK, ServerConfig{DefaultTheme: cfg.DefaultTheme()})
}
}

@ -11,6 +11,7 @@ import (
"net/url" "net/url"
"os" "os"
"path" "path"
"strconv"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@ -890,3 +891,86 @@ func TestSetPrimaryArtist(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.EqualValues(t, 1, count, "expected only one primary artist for track") assert.EqualValues(t, 1, count, "expected only one primary artist for track")
} }
func TestManualListen(t *testing.T) {
t.Run("Submit Listens", doSubmitListens)
ctx := context.Background()
// happy
formdata := url.Values{}
formdata.Set("track_id", "1")
formdata.Set("unix", strconv.FormatInt(time.Now().Unix()-60, 10))
body := formdata.Encode()
resp, err := makeAuthRequest(t, session, "POST", "/apis/web/v1/listen", strings.NewReader(body))
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
count, _ := store.Count(ctx, `SELECT COUNT(*) FROM listens WHERE track_id = $1`, 1)
assert.Equal(t, 2, count)
// 400
formdata.Set("track_id", "1")
formdata.Set("unix", strconv.FormatInt(time.Now().Unix()+60, 10))
body = formdata.Encode()
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/listen", strings.NewReader(body))
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestNowPlaying(t *testing.T) {
t.Run("Submit Listens", doSubmitListens)
// no playing
resp, err := http.DefaultClient.Get(host() + "/apis/web/v1/now-playing")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.NowPlayingResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
require.False(t, result.CurrentlyPlaying)
body := `{
"listen_type": "playing_now",
"payload": [
{
"track_metadata": {
"additional_info": {
"artist_mbids": [
"efc787f0-046f-4a60-beff-77b398c8cdf4"
],
"artist_names": [
"さユり"
],
"duration_ms": 275960,
"recording_mbid": "21524d55-b1f8-45d1-b172-976cba447199",
"release_group_mbid": "3281e0d9-fa44-4337-a8ce-6f264beeae16",
"release_mbid": "eb790e90-0065-4852-b47d-bbeede4aa9fc",
"submission_client": "navidrome",
"submission_client_version": "0.56.1 (fa2cf362)"
},
"artist_name": "さユり",
"release_name": "酸欠少女",
"track_name": "花の塔"
}
}
]
}`
req, err := http.NewRequest("POST", host()+"/apis/listenbrainz/1/submit-listens", strings.NewReader(body))
require.NoError(t, err)
req.Header.Add("Authorization", fmt.Sprintf("Token %s", apikey))
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
respBytes, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, `{"status": "ok"}`, string(respBytes))
// yes playing
resp, err = http.DefaultClient.Get(host() + "/apis/web/v1/now-playing")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
require.True(t, result.CurrentlyPlaying)
require.Equal(t, "花の塔", result.Track.Title)
}

@ -35,6 +35,7 @@ func bindRoutes(
Get("/images/{size}/{filename}", handlers.ImageHandler(db)) Get("/images/{size}/{filename}", handlers.ImageHandler(db))
r.Route("/apis/web/v1", func(r chi.Router) { r.Route("/apis/web/v1", func(r chi.Router) {
r.Get("/config", handlers.GetCfgHandler())
r.Get("/artist", handlers.GetArtistHandler(db)) r.Get("/artist", handlers.GetArtistHandler(db))
r.Get("/artists", handlers.GetArtistsForItemHandler(db)) r.Get("/artists", handlers.GetArtistsForItemHandler(db))
r.Get("/album", handlers.GetAlbumHandler(db)) r.Get("/album", handlers.GetAlbumHandler(db))
@ -44,6 +45,7 @@ func bindRoutes(
r.Get("/top-artists", handlers.GetTopArtistsHandler(db)) r.Get("/top-artists", handlers.GetTopArtistsHandler(db))
r.Get("/listens", handlers.GetListensHandler(db)) r.Get("/listens", handlers.GetListensHandler(db))
r.Get("/listen-activity", handlers.GetListenActivityHandler(db)) r.Get("/listen-activity", handlers.GetListenActivityHandler(db))
r.Get("/now-playing", handlers.NowPlayingHandler(db))
r.Get("/stats", handlers.StatsHandler(db)) r.Get("/stats", handlers.StatsHandler(db))
r.Get("/search", handlers.SearchHandler(db)) r.Get("/search", handlers.SearchHandler(db))
r.Get("/aliases", handlers.GetAliasesHandler(db)) r.Get("/aliases", handlers.GetAliasesHandler(db))
@ -80,6 +82,7 @@ func bindRoutes(
r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db)) r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db))
r.Delete("/album", handlers.DeleteAlbumHandler(db)) r.Delete("/album", handlers.DeleteAlbumHandler(db))
r.Delete("/track", handlers.DeleteTrackHandler(db)) r.Delete("/track", handlers.DeleteTrackHandler(db))
r.Post("/listen", handlers.SubmitListenWithIDHandler(db))
r.Delete("/listen", handlers.DeleteListenHandler(db)) r.Delete("/listen", handlers.DeleteListenHandler(db))
r.Post("/aliases", handlers.CreateAliasHandler(db)) r.Post("/aliases", handlers.CreateAliasHandler(db))
r.Post("/aliases/delete", handlers.DeleteAliasHandler(db)) r.Post("/aliases/delete", handlers.DeleteAliasHandler(db))

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

@ -8,12 +8,14 @@ import (
"errors" "errors"
"fmt" "fmt"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/mbz" "github.com/gabehf/koito/internal/mbz"
"github.com/gabehf/koito/internal/memkv"
"github.com/gabehf/koito/internal/models" "github.com/gabehf/koito/internal/models"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -56,8 +58,9 @@ type SubmitListenOpts struct {
ReleaseGroupMbzID uuid.UUID ReleaseGroupMbzID uuid.UUID
Time time.Time Time time.Time
UserID int32 UserID int32
Client string Client string
IsNowPlaying bool
} }
const ( const (
@ -165,6 +168,14 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
} }
} }
if opts.IsNowPlaying {
if track.Duration == 0 {
memkv.Store.Set(strconv.Itoa(int(opts.UserID)), track.ID)
} else {
memkv.Store.Set(strconv.Itoa(int(opts.UserID)), track.ID, time.Duration(track.Duration)*time.Second)
}
}
if opts.SkipSaveListen { if opts.SkipSaveListen {
return nil return nil
} }
@ -190,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
@ -219,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)
@ -233,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)
@ -241,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"
@ -167,15 +168,15 @@ func getTestGetenv(resource *dockertest.Resource) func(string) string {
func truncateTestData(t *testing.T) { func truncateTestData(t *testing.T) {
err := store.Exec(context.Background(), err := store.Exec(context.Background(),
`TRUNCATE `TRUNCATE
artists, artists,
artist_aliases, artist_aliases,
tracks, tracks,
artist_tracks, artist_tracks,
releases, releases,
artist_releases, artist_releases,
release_aliases, release_aliases,
listens listens
RESTART IDENTITY CASCADE`) RESTART IDENTITY CASCADE`)
require.NoError(t, err) require.NoError(t, err)
} }
@ -184,23 +185,23 @@ func setupTestDataWithMbzIDs(t *testing.T) {
truncateTestData(t) truncateTestData(t)
err := store.Exec(context.Background(), err := store.Exec(context.Background(),
`INSERT INTO artists (musicbrainz_id) `INSERT INTO artists (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000001')`) VALUES ('00000000-0000-0000-0000-000000000001')`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary) `INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`) VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO releases (musicbrainz_id) `INSERT INTO releases (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000101')`) VALUES ('00000000-0000-0000-0000-000000000101')`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO release_aliases (release_id, alias, source, is_primary) `INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'AG! Calling', 'Testing', true)`) VALUES (1, 'AG! Calling', 'Testing', true)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO artist_releases (artist_id, release_id) `INSERT INTO artist_releases (artist_id, release_id)
VALUES (1, 1)`) VALUES (1, 1)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
@ -221,23 +222,23 @@ func setupTestDataSansMbzIDs(t *testing.T) {
truncateTestData(t) truncateTestData(t)
err := store.Exec(context.Background(), err := store.Exec(context.Background(),
`INSERT INTO artists (musicbrainz_id) `INSERT INTO artists (musicbrainz_id)
VALUES (NULL)`) VALUES (NULL)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary) `INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`) VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO releases (musicbrainz_id) `INSERT INTO releases (musicbrainz_id)
VALUES (NULL)`) VALUES (NULL)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO release_aliases (release_id, alias, source, is_primary) `INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'AG! Calling', 'Testing', true)`) VALUES (1, 'AG! Calling', 'Testing', true)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO artist_releases (artist_id, release_id) `INSERT INTO artist_releases (artist_id, release_id)
VALUES (1, 1)`) VALUES (1, 1)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
@ -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)
} }
} }

@ -203,6 +203,22 @@ func TestSubmitListen_CreateAllNoMbzIDsNoArtistNamesNoReleaseTitle(t *testing.T)
)`, "Madeline Kenney") )`, "Madeline Kenney")
require.NoError(t, err) require.NoError(t, err)
assert.True(t, exists, "expected featured artist to be created") assert.True(t, exists, "expected featured artist to be created")
// assert that Rat Tally is the primary artist
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artist_tracks
WHERE artist_id = $1 AND is_primary = $2
)`, 1, true)
require.NoError(t, err)
assert.True(t, exists, "expected primary artist to be marked as primary for track")
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artist_releases
WHERE artist_id = $1 AND is_primary = $2
)`, 1, true)
require.NoError(t, err)
assert.True(t, exists, "expected primary artist to be marked as primary for release")
} }
func TestSubmitListen_MatchAllMbzIDs(t *testing.T) { func TestSubmitListen_MatchAllMbzIDs(t *testing.T) {

@ -3,6 +3,7 @@ package cfg
import ( import (
"errors" "errors"
"fmt" "fmt"
"regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -31,9 +32,12 @@ const (
CONFIG_DIR_ENV = "KOITO_CONFIG_DIR" CONFIG_DIR_ENV = "KOITO_CONFIG_DIR"
DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME" DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME"
DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD" DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD"
DEFAULT_THEME_ENV = "KOITO_DEFAULT_THEME"
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER" DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE" DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
SUBSONIC_URL_ENV = "KOITO_SUBSONIC_URL"
SUBSONIC_PARAMS_ENV = "KOITO_SUBSONIC_PARAMS"
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT" SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS" ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS" CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
@ -42,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 {
@ -60,9 +65,13 @@ type config struct {
lbzRelayToken string lbzRelayToken string
defaultPw string defaultPw string
defaultUsername string defaultUsername string
defaultTheme string
disableDeezer bool disableDeezer bool
disableCAA bool disableCAA bool
disableMusicBrainz bool disableMusicBrainz bool
subsonicUrl string
subsonicParams string
subsonicEnabled bool
skipImport bool skipImport bool
fetchImageDuringImport bool fetchImageDuringImport bool
allowedHosts []string allowedHosts []string
@ -73,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 (
@ -147,6 +157,12 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV)) cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV))
cfg.disableCAA = parseBool(getenv(DISABLE_COVER_ART_ARCHIVE_ENV)) cfg.disableCAA = parseBool(getenv(DISABLE_COVER_ART_ARCHIVE_ENV))
cfg.disableMusicBrainz = parseBool(getenv(DISABLE_MUSICBRAINZ_ENV)) cfg.disableMusicBrainz = parseBool(getenv(DISABLE_MUSICBRAINZ_ENV))
cfg.subsonicUrl = getenv(SUBSONIC_URL_ENV)
cfg.subsonicParams = getenv(SUBSONIC_PARAMS_ENV)
cfg.subsonicEnabled = cfg.subsonicUrl != "" && cfg.subsonicParams != ""
if cfg.subsonicEnabled && (cfg.subsonicUrl == "" || cfg.subsonicParams == "") {
return nil, fmt.Errorf("loadConfig: invalid configuration: both %s and %s must be set in order to use subsonic image fetching", SUBSONIC_URL_ENV, SUBSONIC_PARAMS_ENV)
}
cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV)) cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV))
cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version) cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version)
@ -162,6 +178,8 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
cfg.defaultPw = getenv(DEFAULT_PASSWORD_ENV) cfg.defaultPw = getenv(DEFAULT_PASSWORD_ENV)
} }
cfg.defaultTheme = getenv(DEFAULT_THEME_ENV)
cfg.configDir = getenv(CONFIG_DIR_ENV) cfg.configDir = getenv(CONFIG_DIR_ENV)
if cfg.configDir == "" { if cfg.configDir == "" {
cfg.configDir = "/etc/koito" cfg.configDir = "/etc/koito"
@ -174,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
@ -277,6 +307,12 @@ func DefaultUsername() string {
return globalConfig.defaultUsername return globalConfig.defaultUsername
} }
func DefaultTheme() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.defaultTheme
}
func FullImageCacheEnabled() bool { func FullImageCacheEnabled() bool {
lock.RLock() lock.RLock()
defer lock.RUnlock() defer lock.RUnlock()
@ -301,6 +337,24 @@ func MusicBrainzDisabled() bool {
return globalConfig.disableMusicBrainz return globalConfig.disableMusicBrainz
} }
func SubsonicEnabled() bool {
lock.RLock()
defer lock.RUnlock()
return globalConfig.subsonicEnabled
}
func SubsonicUrl() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.subsonicUrl
}
func SubsonicParams() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.subsonicParams
}
func SkipImport() bool { func SkipImport() bool {
lock.RLock() lock.RLock()
defer lock.RUnlock() defer lock.RUnlock()
@ -349,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
}

@ -98,8 +98,14 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err) return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err)
} }
firstListen, err := d.q.GetFirstListenFromRelease(ctx, ret.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err)
}
ret.ListenCount = count ret.ListenCount = count
ret.TimeListened = seconds ret.TimeListened = seconds
ret.FirstListen = firstListen.ListenedAt.Unix()
return ret, nil return ret, nil
} }
@ -144,6 +150,7 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al
err = qtx.AssociateArtistToRelease(ctx, repository.AssociateArtistToReleaseParams{ err = qtx.AssociateArtistToRelease(ctx, repository.AssociateArtistToReleaseParams{
ArtistID: artistId, ArtistID: artistId,
ReleaseID: r.ID, ReleaseID: r.ID,
IsPrimary: opts.ArtistIDs[0] == artistId,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("SaveAlbum: AssociateArtistToRelease: %w", err) return nil, fmt.Errorf("SaveAlbum: AssociateArtistToRelease: %w", err)

@ -41,6 +41,10 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil { if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err) return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
} }
firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err)
}
return &models.Artist{ return &models.Artist{
ID: row.ID, ID: row.ID,
MbzID: row.MusicBrainzID, MbzID: row.MusicBrainzID,
@ -49,6 +53,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
Image: row.Image, Image: row.Image,
ListenCount: count, ListenCount: count,
TimeListened: seconds, TimeListened: seconds,
FirstListen: firstListen.ListenedAt.Unix(),
}, nil }, nil
} else if opts.MusicBrainzID != uuid.Nil { } else if opts.MusicBrainzID != uuid.Nil {
l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID) l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID)
@ -71,14 +76,19 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil { if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err) return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
} }
firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err)
}
return &models.Artist{ return &models.Artist{
ID: row.ID, ID: row.ID,
MbzID: row.MusicBrainzID, MbzID: row.MusicBrainzID,
Name: row.Name, Name: row.Name,
Aliases: row.Aliases, Aliases: row.Aliases,
Image: row.Image, Image: row.Image,
TimeListened: seconds,
ListenCount: count, ListenCount: count,
TimeListened: seconds,
FirstListen: firstListen.ListenedAt.Unix(),
}, nil }, nil
} else if opts.Name != "" { } else if opts.Name != "" {
l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name) l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name)
@ -101,6 +111,10 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil { if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err) return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
} }
firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err)
}
return &models.Artist{ return &models.Artist{
ID: row.ID, ID: row.ID,
MbzID: row.MusicBrainzID, MbzID: row.MusicBrainzID,
@ -109,6 +123,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
Image: row.Image, Image: row.Image,
ListenCount: count, ListenCount: count,
TimeListened: seconds, TimeListened: seconds,
FirstListen: firstListen.ListenedAt.Unix(),
}, nil }, nil
} else { } else {
return nil, errors.New("insufficient information to get artist") return nil, errors.New("insufficient information to get artist")

@ -89,8 +89,14 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err) return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err)
} }
firstListen, err := d.q.GetFirstListenFromTrack(ctx, track.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err)
}
track.ListenCount = count track.ListenCount = count
track.TimeListened = seconds track.TimeListened = seconds
track.FirstListen = firstListen.ListenedAt.Unix()
return &track, nil return &track, nil
} }
@ -132,8 +138,9 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
// insert associated artists // insert associated artists
for _, aid := range opts.ArtistIDs { for _, aid := range opts.ArtistIDs {
err = qtx.AssociateArtistToTrack(ctx, repository.AssociateArtistToTrackParams{ err = qtx.AssociateArtistToTrack(ctx, repository.AssociateArtistToTrackParams{
ArtistID: aid, ArtistID: aid,
TrackID: trackRow.ID, TrackID: trackRow.ID,
IsPrimary: opts.ArtistIDs[0] == aid,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("SaveTrack: AssociateArtistToTrack: %w", err) return nil, fmt.Errorf("SaveTrack: AssociateArtistToTrack: %w", err)

@ -12,14 +12,17 @@ import (
) )
type ImageSource struct { type ImageSource struct {
deezerEnabled bool deezerEnabled bool
deezerC *DeezerClient deezerC *DeezerClient
caaEnabled bool subsonicEnabled bool
subsonicC *SubsonicClient
caaEnabled bool
} }
type ImageSourceOpts struct { type ImageSourceOpts struct {
UserAgent string UserAgent string
EnableCAA bool EnableCAA bool
EnableDeezer bool EnableDeezer bool
EnableSubsonic bool
} }
var once sync.Once var once sync.Once
@ -48,6 +51,10 @@ func Initialize(opts ImageSourceOpts) {
imgsrc.deezerEnabled = true imgsrc.deezerEnabled = true
imgsrc.deezerC = NewDeezerClient() imgsrc.deezerC = NewDeezerClient()
} }
if opts.EnableSubsonic {
imgsrc.subsonicEnabled = true
imgsrc.subsonicC = NewSubsonicClient()
}
}) })
} }
@ -57,6 +64,16 @@ func Shutdown() {
func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) { func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
if imgsrc.subsonicEnabled {
img, err := imgsrc.subsonicC.GetArtistImage(ctx, opts.Aliases[0])
if err != nil {
return "", err
}
if img != "" {
return img, nil
}
l.Debug().Msg("Could not find artist image from Subsonic")
}
if imgsrc.deezerC != nil { if imgsrc.deezerC != nil {
img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases) img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases)
if err != nil { if err != nil {
@ -69,6 +86,16 @@ func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
} }
func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) { func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
if imgsrc.subsonicEnabled {
img, err := imgsrc.subsonicC.GetAlbumImage(ctx, opts.Artists[0], opts.Album)
if err != nil {
return "", err
}
if img != "" {
return img, nil
}
l.Debug().Msg("Could not find album cover from Subsonic")
}
if imgsrc.caaEnabled { if imgsrc.caaEnabled {
l.Debug().Msg("Attempting to find album image from CoverArtArchive") l.Debug().Msg("Attempting to find album image from CoverArtArchive")
if opts.ReleaseMbzID != nil && *opts.ReleaseMbzID != uuid.Nil { if opts.ReleaseMbzID != nil && *opts.ReleaseMbzID != uuid.Nil {

@ -0,0 +1,137 @@
package images
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/queue"
)
type SubsonicClient struct {
url string
userAgent string
authParams string
requestQueue *queue.RequestQueue
}
type SubsonicAlbumResponse struct {
SubsonicResponse struct {
Status string `json:"status"`
SearchResult3 struct {
Album []struct {
CoverArt string `json:"coverArt"`
} `json:"album"`
} `json:"searchResult3"`
} `json:"subsonic-response"`
}
type SubsonicArtistResponse struct {
SubsonicResponse struct {
Status string `json:"status"`
SearchResult3 struct {
Artist []struct {
ArtistImageUrl string `json:"artistImageUrl"`
} `json:"artist"`
} `json:"searchResult3"`
} `json:"subsonic-response"`
}
const (
subsonicAlbumSearchFmtStr = "/rest/search3?%s&f=json&query=%s&v=1.13.0&c=koito&artistCount=0&songCount=0&albumCount=1"
subsonicArtistSearchFmtStr = "/rest/search3?%s&f=json&query=%s&v=1.13.0&c=koito&artistCount=1&songCount=0&albumCount=0"
subsonicCoverArtFmtStr = "/rest/getCoverArt?%s&id=%s&v=1.13.0&c=koito"
)
func NewSubsonicClient() *SubsonicClient {
ret := new(SubsonicClient)
ret.url = cfg.SubsonicUrl()
ret.userAgent = cfg.UserAgent()
ret.authParams = cfg.SubsonicParams()
ret.requestQueue = queue.NewRequestQueue(5, 5)
return ret
}
func (c *SubsonicClient) queue(ctx context.Context, req *http.Request) ([]byte, error) {
l := logger.FromContext(ctx)
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json")
resultChan := c.requestQueue.Enqueue(func(client *http.Client, done chan<- queue.RequestResult) {
resp, err := client.Do(req)
if err != nil {
l.Debug().Err(err).Str("url", req.RequestURI).Msg("Failed to contact ImageSrc")
done <- queue.RequestResult{Err: err}
return
} else if resp.StatusCode >= 300 || resp.StatusCode < 200 {
err = fmt.Errorf("recieved non-ok status from Subsonic: %s", resp.Status)
done <- queue.RequestResult{Body: nil, Err: err}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
done <- queue.RequestResult{Body: body, Err: err}
})
result := <-resultChan
return result.Body, result.Err
}
func (c *SubsonicClient) getEntity(ctx context.Context, endpoint string, result any) error {
l := logger.FromContext(ctx)
url := c.url + endpoint
l.Debug().Msgf("Sending request to ImageSrc: GET %s", url)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("getEntity: %w", err)
}
l.Debug().Msg("Adding ImageSrc request to queue")
body, err := c.queue(ctx, req)
if err != nil {
l.Err(err).Msg("Subsonic request failed")
return fmt.Errorf("getEntity: %w", err)
}
err = json.Unmarshal(body, result)
if err != nil {
l.Err(err).Msg("Failed to unmarshal Subsonic response")
return fmt.Errorf("getEntity: %w", err)
}
return nil
}
func (c *SubsonicClient) GetAlbumImage(ctx context.Context, artist, album string) (string, error) {
l := logger.FromContext(ctx)
resp := new(SubsonicAlbumResponse)
l.Debug().Msgf("Finding album image for %s from artist %s", album, artist)
err := c.getEntity(ctx, fmt.Sprintf(subsonicAlbumSearchFmtStr, c.authParams, url.QueryEscape(artist+" "+album)), resp)
if err != nil {
return "", fmt.Errorf("GetAlbumImage: %v", err)
}
l.Debug().Any("subsonic_response", resp).Send()
if len(resp.SubsonicResponse.SearchResult3.Album) < 1 || resp.SubsonicResponse.SearchResult3.Album[0].CoverArt == "" {
return "", fmt.Errorf("GetAlbumImage: failed to get album art")
}
return cfg.SubsonicUrl() + fmt.Sprintf(subsonicCoverArtFmtStr, c.authParams, url.QueryEscape(resp.SubsonicResponse.SearchResult3.Album[0].CoverArt)), nil
}
func (c *SubsonicClient) GetArtistImage(ctx context.Context, artist string) (string, error) {
l := logger.FromContext(ctx)
resp := new(SubsonicArtistResponse)
l.Debug().Msgf("Finding artist image for %s", artist)
err := c.getEntity(ctx, fmt.Sprintf(subsonicArtistSearchFmtStr, c.authParams, url.QueryEscape(artist)), resp)
if err != nil {
return "", fmt.Errorf("GetArtistImage: %v", err)
}
l.Debug().Any("subsonic_response", resp).Send()
if len(resp.SubsonicResponse.SearchResult3.Artist) < 1 || resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl == "" {
return "", fmt.Errorf("GetArtistImage: failed to get artist art")
}
return resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl, nil
}

@ -0,0 +1,110 @@
package memkv
import (
"sync"
"time"
)
type item struct {
value interface{}
expiresAt time.Time
}
type InMemoryStore struct {
data map[string]item
defaultExpiration time.Duration
mu sync.RWMutex
stopJanitor chan struct{}
}
var Store *InMemoryStore
func init() {
Store = NewStore(10 * time.Minute)
}
func NewStore(defaultExpiration time.Duration) *InMemoryStore {
s := &InMemoryStore{
data: make(map[string]item),
defaultExpiration: defaultExpiration,
stopJanitor: make(chan struct{}),
}
go s.janitor(1 * time.Minute)
return s
}
func (s *InMemoryStore) Set(key string, value interface{}, expiration ...time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
exp := s.defaultExpiration
if len(expiration) > 0 {
exp = expiration[0]
}
var expiresAt time.Time
if exp > 0 {
expiresAt = time.Now().Add(exp)
}
s.data[key] = item{
value: value,
expiresAt: expiresAt,
}
}
func (s *InMemoryStore) Get(key string) (interface{}, bool) {
s.mu.RLock()
it, found := s.data[key]
s.mu.RUnlock()
if !found {
return nil, false
}
if !it.expiresAt.IsZero() && time.Now().After(it.expiresAt) {
s.Delete(key)
return nil, false
}
return it.value, true
}
func (s *InMemoryStore) Delete(key string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.data, key)
}
func (s *InMemoryStore) janitor(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.cleanup()
case <-s.stopJanitor:
return
}
}
}
func (s *InMemoryStore) cleanup() {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
for k, it := range s.data {
if !it.expiresAt.IsZero() && now.After(it.expiresAt) {
delete(s.data, k)
}
}
}
func (s *InMemoryStore) Close() {
close(s.stopJanitor)
}

@ -11,6 +11,7 @@ type Album struct {
VariousArtists bool `json:"is_various_artists"` VariousArtists bool `json:"is_various_artists"`
ListenCount int64 `json:"listen_count"` ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"` TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
} }
// type SimpleAlbum struct { // type SimpleAlbum struct {

@ -10,6 +10,7 @@ type Artist struct {
Image *uuid.UUID `json:"image"` Image *uuid.UUID `json:"image"`
ListenCount int64 `json:"listen_count"` ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"` TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
IsPrimary bool `json:"is_primary,omitempty"` IsPrimary bool `json:"is_primary,omitempty"`
} }
@ -27,5 +28,6 @@ type ArtistWithFullAliases struct {
ImageSource string `json:"image_source,omitempty"` ImageSource string `json:"image_source,omitempty"`
ListenCount int64 `json:"listen_count"` ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"` TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
IsPrimary bool `json:"is_primary,omitempty"` IsPrimary bool `json:"is_primary,omitempty"`
} }

@ -12,4 +12,5 @@ type Track struct {
Image *uuid.UUID `json:"image"` Image *uuid.UUID `json:"image"`
AlbumID int32 `json:"album_id"` AlbumID int32 `json:"album_id"`
TimeListened int64 `json:"time_listened"` TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
} }

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.29.0 // sqlc v1.30.0
// source: alias.sql // source: alias.sql
package repository package repository

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.29.0 // sqlc v1.30.0
// source: artist.sql // source: artist.sql
package repository package repository

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.29.0 // sqlc v1.30.0
package repository package repository

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.29.0 // sqlc v1.30.0
// source: etc.sql // source: etc.sql
package repository package repository

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.29.0 // sqlc v1.30.0
// source: listen.sql // source: listen.sql
package repository package repository
@ -190,6 +190,73 @@ func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) erro
return err return err
} }
const getFirstListenFromArtist = `-- name: GetFirstListenFromArtist :one
SELECT
l.track_id, l.listened_at, l.client, l.user_id
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
JOIN artist_tracks at ON t.id = at.track_id
WHERE at.artist_id = $1
ORDER BY l.listened_at ASC
LIMIT 1
`
func (q *Queries) GetFirstListenFromArtist(ctx context.Context, artistID int32) (Listen, error) {
row := q.db.QueryRow(ctx, getFirstListenFromArtist, artistID)
var i Listen
err := row.Scan(
&i.TrackID,
&i.ListenedAt,
&i.Client,
&i.UserID,
)
return i, err
}
const getFirstListenFromRelease = `-- name: GetFirstListenFromRelease :one
SELECT
l.track_id, l.listened_at, l.client, l.user_id
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.release_id = $1
ORDER BY l.listened_at ASC
LIMIT 1
`
func (q *Queries) GetFirstListenFromRelease(ctx context.Context, releaseID int32) (Listen, error) {
row := q.db.QueryRow(ctx, getFirstListenFromRelease, releaseID)
var i Listen
err := row.Scan(
&i.TrackID,
&i.ListenedAt,
&i.Client,
&i.UserID,
)
return i, err
}
const getFirstListenFromTrack = `-- name: GetFirstListenFromTrack :one
SELECT
l.track_id, l.listened_at, l.client, l.user_id
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.id = $1
ORDER BY l.listened_at ASC
LIMIT 1
`
func (q *Queries) GetFirstListenFromTrack(ctx context.Context, id int32) (Listen, error) {
row := q.db.QueryRow(ctx, getFirstListenFromTrack, id)
var i Listen
err := row.Scan(
&i.TrackID,
&i.ListenedAt,
&i.Client,
&i.UserID,
)
return i, err
}
const getLastListensFromArtistPaginated = `-- name: GetLastListensFromArtistPaginated :many const getLastListensFromArtistPaginated = `-- name: GetLastListensFromArtistPaginated :many
SELECT SELECT
l.track_id, l.listened_at, l.client, l.user_id, l.track_id, l.listened_at, l.client, l.user_id,

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.29.0 // sqlc v1.30.0
package repository package repository

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.29.0 // sqlc v1.30.0
// source: release.sql // source: release.sql
package repository package repository
@ -14,18 +14,19 @@ import (
) )
const associateArtistToRelease = `-- name: AssociateArtistToRelease :exec const associateArtistToRelease = `-- name: AssociateArtistToRelease :exec
INSERT INTO artist_releases (artist_id, release_id) INSERT INTO artist_releases (artist_id, release_id, is_primary)
VALUES ($1, $2) VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
` `
type AssociateArtistToReleaseParams struct { type AssociateArtistToReleaseParams struct {
ArtistID int32 ArtistID int32
ReleaseID int32 ReleaseID int32
IsPrimary bool
} }
func (q *Queries) AssociateArtistToRelease(ctx context.Context, arg AssociateArtistToReleaseParams) error { func (q *Queries) AssociateArtistToRelease(ctx context.Context, arg AssociateArtistToReleaseParams) error {
_, err := q.db.Exec(ctx, associateArtistToRelease, arg.ArtistID, arg.ReleaseID) _, err := q.db.Exec(ctx, associateArtistToRelease, arg.ArtistID, arg.ReleaseID, arg.IsPrimary)
return err return err
} }

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.29.0 // sqlc v1.30.0
// source: search.sql // source: search.sql
package repository package repository

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.29.0 // sqlc v1.30.0
// source: sessions.sql // source: sessions.sql
package repository package repository

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.29.0 // sqlc v1.30.0
// source: track.sql // source: track.sql
package repository package repository
@ -13,18 +13,19 @@ import (
) )
const associateArtistToTrack = `-- name: AssociateArtistToTrack :exec const associateArtistToTrack = `-- name: AssociateArtistToTrack :exec
INSERT INTO artist_tracks (artist_id, track_id) INSERT INTO artist_tracks (artist_id, track_id, is_primary)
VALUES ($1, $2) VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
` `
type AssociateArtistToTrackParams struct { type AssociateArtistToTrackParams struct {
ArtistID int32 ArtistID int32
TrackID int32 TrackID int32
IsPrimary bool
} }
func (q *Queries) AssociateArtistToTrack(ctx context.Context, arg AssociateArtistToTrackParams) error { func (q *Queries) AssociateArtistToTrack(ctx context.Context, arg AssociateArtistToTrackParams) error {
_, err := q.db.Exec(ctx, associateArtistToTrack, arg.ArtistID, arg.TrackID) _, err := q.db.Exec(ctx, associateArtistToTrack, arg.ArtistID, arg.TrackID, arg.IsPrimary)
return err return err
} }

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.29.0 // sqlc v1.30.0
// source: users.sql // source: users.sql
package repository package repository

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.29.0 // sqlc v1.30.0
// source: year.sql // source: year.sql
package repository package repository

Loading…
Cancel
Save