Pre-release version v0.0.14 (#96)

* add dev branch container to workflow

* correctly set the default range of ActivityGrid

* fix: set name/short_name to koito (#61)

* fix dev container push workflow

* fix: race condition with using getComputedStyle primary color for dynamic activity grid darkening (#76)

* Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening

Instead just use the color from the current theme directly. Tested works on initial load and theme changes.
Fixes https://github.com/gabehf/Koito/issues/75

* Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name
Split name out of the Theme struct to simplify custom theme saving/reading

* fix: set first artist listed as primary by default (#81)

* feat: add server-side configuration with default theme (#90)

* docs: add example for usage of the main listenbrainz instance (#71)

* docs: add example for usage of the main listenbrainz instance

* Update scrobbler.md

---------

Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com>

* feat: add server-side cfg and default theme

* fix: repair custom theme

---------

Co-authored-by: m0d3rnX <jesper@posteo.de>

* docs: add default theme cfg option to docs

* feat: add ability to manually scrobble track (#91)

* feat: add button to manually scrobble from ui

* fix: ensure timestamp is in the past, log fix

* test: add integration test

* feat: add first listened to dates for media items (#92)

* fix: ensure error checks for ErrNoRows

* feat: add now playing endpoint and ui (#93)

* wip

* feat: add now playing

* fix: set default theme when config is not set

* feat: fetch images from subsonic server (#94)

* fix: useQuery instead of useEffect for now playing

* feat: custom artist separator regex (#95)

* Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening

Instead just use the color from the current theme directly. Tested works on initial load and theme changes.
Fixes https://github.com/gabehf/Koito/issues/75

* Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name
Split name out of the Theme struct to simplify custom theme saving/reading

* feat: add server-side configuration with default theme (#90)

* docs: add example for usage of the main listenbrainz instance (#71)

* docs: add example for usage of the main listenbrainz instance

* Update scrobbler.md

---------

Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com>

* feat: add server-side cfg and default theme

* fix: repair custom theme

---------

Co-authored-by: m0d3rnX <jesper@posteo.de>

* fix: rebase errors

---------

Co-authored-by: pet <128837728+againstpetra@users.noreply.github.com>
Co-authored-by: mlandry <mike.landry@gmail.com>
Co-authored-by: m0d3rnX <jesper@posteo.de>
pull/98/head v0.0.14
Gabe Farrell 3 weeks ago committed by GitHub
parent bf0ec68cfe
commit 36f984a1a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -13,6 +13,8 @@ on:
push: push:
tags: tags:
- 'v*' - 'v*'
branches:
- dev
workflow_dispatch: workflow_dispatch:
@ -43,20 +45,15 @@ jobs:
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
@ -88,9 +78,32 @@ jobs:
KOITO_VERSION=${{ env.KOITO_VERSION }} KOITO_VERSION=${{ env.KOITO_VERSION }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
- name: Generate artifact attestation push_dev:
uses: actions/attest-build-provenance@v2 name: Push Docker image (dev branch)
if: github.ref == 'refs/heads/dev'
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: Build and push dev image
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,112 +1,170 @@
interface getItemsArgs { interface getItemsArgs {
limit: number, limit: number;
period: string, period: string;
page: number, page: number;
artist_id?: number, artist_id?: number;
album_id?: number, album_id?: number;
track_id?: number track_id?: number;
} }
interface getActivityArgs { interface getActivityArgs {
step: string step: string;
range: number range: number;
month: number month: number;
year: number year: number;
artist_id: number artist_id: number;
album_id: number album_id: number;
track_id: number track_id: number;
} }
function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> { function getLastListens(
return fetch(`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Listen>>) args: getItemsArgs
): Promise<PaginatedResponse<Listen>> {
return fetch(
`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Listen>>);
} }
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> { function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> {
if (args.artist_id) { if (args.artist_id) {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>) return fetch(
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
} else if (args.album_id) { } else if (args.album_id) {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>) return fetch(
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
} else { } else {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>) return fetch(
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
} }
} }
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> { function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> {
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}` const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
if (args.artist_id) { if (args.artist_id) {
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>) return fetch(baseUri + `&artist_id=${args.artist_id}`).then(
(r) => r.json() as Promise<PaginatedResponse<Album>>
);
} else { } else {
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>) return fetch(baseUri).then(
(r) => r.json() as Promise<PaginatedResponse<Album>>
);
} }
} }
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> { function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}` const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`;
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Artist>>) return fetch(baseUri).then(
(r) => r.json() as Promise<PaginatedResponse<Artist>>
);
} }
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> { function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
return fetch(`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`).then(r => r.json() as Promise<ListenActivityItem[]>) return fetch(
`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
).then((r) => r.json() as Promise<ListenActivityItem[]>);
} }
function getStats(period: string): Promise<Stats> { function getStats(period: string): Promise<Stats> {
return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>) return fetch(`/apis/web/v1/stats?period=${period}`).then(
(r) => r.json() as Promise<Stats>
);
} }
function search(q: string): Promise<SearchResponse> { function search(q: string): Promise<SearchResponse> {
q = encodeURIComponent(q) q = encodeURIComponent(q);
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>) return fetch(`/apis/web/v1/search?q=${q}`).then(
(r) => r.json() as Promise<SearchResponse>
);
} }
function imageUrl(id: string, size: string) { function imageUrl(id: string, size: string) {
if (!id) { if (!id) {
id = 'default' id = "default";
} }
return `/images/${size}/${id}` return `/images/${size}/${id}`;
} }
function replaceImage(form: FormData): Promise<Response> { function replaceImage(form: FormData): Promise<Response> {
return fetch(`/apis/web/v1/replace-image`, { return fetch(`/apis/web/v1/replace-image`, {
method: "POST", method: "POST",
body: form, body: form,
}) });
} }
function mergeTracks(from: number, to: number): Promise<Response> { function mergeTracks(from: number, to: number): Promise<Response> {
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, { return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
method: "POST", method: "POST",
}) });
} }
function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise<Response> { function mergeAlbums(
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, { from: number,
to: number,
replaceImage: boolean
): Promise<Response> {
return fetch(
`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
{
method: "POST", method: "POST",
}) }
} );
function mergeArtists(from: number, to: number, replaceImage: boolean): Promise<Response> { }
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, { function mergeArtists(
from: number,
to: number,
replaceImage: boolean
): Promise<Response> {
return fetch(
`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
{
method: "POST", method: "POST",
}) }
} );
function login(username: string, password: string, remember: boolean): Promise<Response> { }
const form = new URLSearchParams function login(
form.append('username', username) username: string,
form.append('password', password) password: string,
form.append('remember_me', String(remember)) remember: boolean
): Promise<Response> {
const form = new URLSearchParams();
form.append("username", username);
form.append("password", password);
form.append("remember_me", String(remember));
return fetch(`/apis/web/v1/login`, { return fetch(`/apis/web/v1/login`, {
method: "POST", method: "POST",
body: form, body: form,
}) });
} }
function logout(): Promise<Response> { function logout(): Promise<Response> {
return fetch(`/apis/web/v1/logout`, { return fetch(`/apis/web/v1/logout`, {
method: "POST", method: "POST",
}) });
}
function getCfg(): Promise<Config> {
return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise<Config>);
}
function submitListen(id: string, ts: Date): Promise<Response> {
const form = new URLSearchParams();
form.append("track_id", id);
const ms = new Date(ts).getTime();
const unix = Math.floor(ms / 1000);
form.append("unix", unix.toString());
return fetch(`/apis/web/v1/listen`, {
method: "POST",
body: form,
});
} }
function getApiKeys(): Promise<ApiKey[]> { 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,
@ -115,7 +173,7 @@ const createApiKey = async (label: string): Promise<ApiKey> => {
let errorMessage = `error: ${r.status}`; let errorMessage = `error: ${r.status}`;
try { try {
const errorData: ApiError = await r.json(); const errorData: ApiError = await r.json();
if (errorData && typeof errorData.error === 'string') { if (errorData && typeof errorData.error === "string") {
errorMessage = errorData.error; errorMessage = errorData.error;
} }
} catch (e) { } catch (e) {
@ -128,75 +186,94 @@ const createApiKey = async (label: string): Promise<ApiKey> => {
}; };
function deleteApiKey(id: number): Promise<Response> { function deleteApiKey(id: number): Promise<Response> {
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, { return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function updateApiKeyLabel(id: number, label: string): Promise<Response> { function updateApiKeyLabel(id: number, label: string): Promise<Response> {
const form = new URLSearchParams const form = new URLSearchParams();
form.append('id', String(id)) form.append("id", String(id));
form.append('label', label) form.append("label", label);
return fetch(`/apis/web/v1/user/apikeys`, { return fetch(`/apis/web/v1/user/apikeys`, {
method: "PATCH", method: "PATCH",
body: form, body: form,
}) });
} }
function deleteItem(itemType: string, id: number): Promise<Response> { function deleteItem(itemType: string, id: number): Promise<Response> {
return fetch(`/apis/web/v1/${itemType}?id=${id}`, { return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function updateUser(username: string, password: string) { function updateUser(username: string, password: string) {
const form = new URLSearchParams const form = new URLSearchParams();
form.append('username', username) form.append("username", username);
form.append('password', password) form.append("password", password);
return fetch(`/apis/web/v1/user`, { return fetch(`/apis/web/v1/user`, {
method: "PATCH", method: "PATCH",
body: form, body: form,
}) });
} }
function getAliases(type: string, id: number): Promise<Alias[]> { function getAliases(type: string, id: number): Promise<Alias[]> {
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise<Alias[]>) return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(
(r) => r.json() as Promise<Alias[]>
);
} }
function createAlias(type: string, id: number, alias: string): Promise<Response> { function createAlias(
const form = new URLSearchParams type: string,
form.append(`${type}_id`, String(id)) id: number,
form.append('alias', alias) alias: string
): Promise<Response> {
const form = new URLSearchParams();
form.append(`${type}_id`, String(id));
form.append("alias", alias);
return fetch(`/apis/web/v1/aliases`, { return fetch(`/apis/web/v1/aliases`, {
method: 'POST', method: "POST",
body: form, body: form,
}) });
} }
function deleteAlias(type: string, id: number, alias: string): Promise<Response> { function deleteAlias(
const form = new URLSearchParams type: string,
form.append(`${type}_id`, String(id)) id: number,
form.append('alias', alias) alias: string
): Promise<Response> {
const form = new URLSearchParams();
form.append(`${type}_id`, String(id));
form.append("alias", alias);
return fetch(`/apis/web/v1/aliases/delete`, { return fetch(`/apis/web/v1/aliases/delete`, {
method: "POST", method: "POST",
body: form, body: form,
}) });
} }
function setPrimaryAlias(type: string, id: number, alias: string): Promise<Response> { function setPrimaryAlias(
const form = new URLSearchParams type: string,
form.append(`${type}_id`, String(id)) id: number,
form.append('alias', alias) alias: string
): Promise<Response> {
const form = new URLSearchParams();
form.append(`${type}_id`, String(id));
form.append("alias", alias);
return fetch(`/apis/web/v1/aliases/primary`, { return fetch(`/apis/web/v1/aliases/primary`, {
method: "POST", method: "POST",
body: form, body: form,
}) });
} }
function getAlbum(id: number): Promise<Album> { function getAlbum(id: number): Promise<Album> {
return fetch(`/apis/web/v1/album?id=${id}`).then(r => r.json() as Promise<Album>) return fetch(`/apis/web/v1/album?id=${id}`).then(
(r) => r.json() as Promise<Album>
);
} }
function deleteListen(listen: Listen): Promise<Response> { function deleteListen(listen: Listen): Promise<Response> {
const ms = new Date(listen.time).getTime() const ms = new Date(listen.time).getTime();
const unix= Math.floor(ms / 1000); const unix = Math.floor(ms / 1000);
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, { return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function getExport() { function getExport() {}
function getNowPlaying(): Promise<NowPlaying> {
return fetch("/apis/web/v1/now-playing").then((r) => r.json());
} }
export { export {
@ -214,6 +291,7 @@ export {
imageUrl, imageUrl,
login, login,
logout, logout,
getCfg,
deleteItem, deleteItem,
updateUser, updateUser,
getAliases, getAliases,
@ -227,88 +305,100 @@ export {
deleteListen, deleteListen,
getAlbum, getAlbum,
getExport, 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,
@ -323,5 +413,7 @@ export type {
User, User,
Alias, Alias,
ApiKey, ApiKey,
ApiError ApiError,
} Config,
NowPlaying,
};

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

@ -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,47 +1,64 @@
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: [
"last-listens",
{
limit: props.limit, limit: props.limit,
period: 'all_time', period: "all_time",
artist_id: props.artistId, artist_id: props.artistId,
album_id: props.albumId, album_id: props.albumId,
track_id: props.trackId track_id: props.trackId,
}], },
],
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
}) });
const { data: npData } = useQuery({
queryKey: ["now-playing"],
queryFn: () => getNowPlaying(),
});
const [items, setItems] = useState<Listen[] | null>(null) const [items, setItems] = useState<Listen[] | null>(null);
const handleDelete = async (listen: Listen) => { const handleDelete = async (listen: Listen) => {
if (!data) return if (!data) return;
try { try {
const res = await deleteListen(listen) const res = await deleteListen(listen);
if (res.ok || (res.status >= 200 && res.status < 300)) { if (res.ok || (res.status >= 200 && res.status < 300)) {
setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time)) setItems((prev) =>
(prev ?? data.items).filter((i) => i.time !== listen.time)
);
} else { } else {
console.error("Failed to delete listen:", res.status) console.error("Failed to delete listen:", res.status);
} }
} catch (err) { } catch (err) {
console.error("Error deleting listen:", err) console.error("Error deleting listen:", err);
}
} }
};
if (isPending) { if (isPending) {
return ( return (
@ -49,18 +66,18 @@ export default function LastPlays(props: Props) {
<h2>Last Played</h2> <h2>Last Played</h2>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
) );
} }
if (isError) { if (isError) {
return <p className="error">Error: {error.message}</p> return <p className="error">Error: {error.message}</p>;
} }
const listens = items ?? data.items const listens = items ?? data.items;
let params = '' let params = "";
params += props.artistId ? `&artist_id=${props.artistId}` : '' params += props.artistId ? `&artist_id=${props.artistId}` : "";
params += props.albumId ? `&album_id=${props.albumId}` : '' params += props.albumId ? `&album_id=${props.albumId}` : "";
params += props.trackId ? `&track_id=${props.trackId}` : '' params += props.trackId ? `&track_id=${props.trackId}` : "";
return ( return (
<div className="text-sm sm:text-[16px]"> <div className="text-sm sm:text-[16px]">
@ -69,9 +86,33 @@ export default function LastPlays(props: Props) {
</h2> </h2>
<table className="-ml-4"> <table className="-ml-4">
<tbody> <tbody>
{props.showNowPlaying && npData && npData.currently_playing && (
<tr className="group hover:bg-[--color-bg-secondary]">
<td className="w-[18px] pr-2 align-middle"></td>
<td className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0">
Now Playing
</td>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
{props.hideArtists ? null : (
<>
<ArtistLinks artists={npData.track.artists} /> {" "}
</>
)}
<Link
className="hover:text-[--color-fg-secondary]"
to={`/track/${npData.track.id}`}
>
{npData.track.title}
</Link>
</td>
</tr>
)}
{listens.map((item) => ( {listens.map((item) => (
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]"> <tr
<td className="w-[18px] pr-2 align-middle" > key={`last_listen_${item.time}`}
className="group hover:bg-[--color-bg-secondary]"
>
<td className="w-[18px] pr-2 align-middle">
<button <button
onClick={() => handleDelete(item)} onClick={() => handleDelete(item)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)" className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
@ -90,7 +131,7 @@ export default function LastPlays(props: Props) {
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]"> <td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
{props.hideArtists ? null : ( {props.hideArtists ? null : (
<> <>
<ArtistLinks artists={item.track.artists} /> {' '} <ArtistLinks artists={item.track.artists} /> {" "}
</> </>
)} )}
<Link <Link
@ -105,5 +146,5 @@ export default function LastPlays(props: Props) {
</tbody> </tbody>
</table> </table>
</div> </div>
) );
} }

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

@ -1,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,12 +1,11 @@
// 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",
@ -22,45 +21,55 @@ export function ThemeSwitcher() {
warning: "#f5b851", warning: "#f5b851",
success: "#8fc48f", success: "#8fc48f",
info: "#87b8dd", info: "#87b8dd",
} };
const { setCustomTheme, getCustomTheme } = useTheme() const { setCustomTheme, getCustomTheme, resetTheme } = useTheme();
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")) const [custom, setCustom] = useState(
JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")
);
const handleCustomTheme = () => { const handleCustomTheme = () => {
console.log(custom) console.log(custom);
try { try {
const theme = JSON.parse(custom) const themeData = JSON.parse(custom);
theme.name = "custom" setCustomTheme(themeData);
setCustomTheme(theme) setCustom(JSON.stringify(themeData, null, " "));
delete theme.name console.log(themeData);
setCustom(JSON.stringify(theme, null, " ")) } catch (err) {
console.log(theme) console.log(err);
} catch(err) {
console.log(err)
}
} }
};
useEffect(() => {
if (theme) {
setTheme(theme)
}
}, [theme]);
return ( return (
<div className='flex flex-col gap-10'> <div className="flex flex-col gap-10">
<div> <div>
<div className="flex items-center gap-3">
<h2>Select Theme</h2> <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"> <div className="grid grid-cols-2 items-center gap-2">
{themes.map((t) => ( {Object.entries(themes).map(([name, themeData]) => (
<ThemeOption setTheme={setTheme} key={t.name} theme={t} /> <ThemeOption
setTheme={setTheme}
key={name}
theme={themeData}
themeName={name}
/>
))} ))}
</div> </div>
</div> </div>
<div> <div>
<h2>Use Custom Theme</h2> <h2>Use Custom Theme</h2>
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg"> <div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
<textarea name="custom-theme" onChange={(e) => setCustom(e.target.value)} id="custom-theme-input" className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md" value={custom} /> <textarea
name="custom-theme"
onChange={(e) => setCustom(e.target.value)}
id="custom-theme-input"
className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md"
value={custom}
/>
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton> <AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
</div> </div>
</div> </div>

@ -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,10 +1,19 @@
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;
theme: Theme;
setTheme: (theme: string) => void; setTheme: (theme: string) => void;
resetTheme: () => void;
setCustomTheme: (theme: Theme) => void; setCustomTheme: (theme: Theme) => void;
getCustomTheme: () => Theme | undefined; getCustomTheme: () => Theme | undefined;
} }
@ -12,13 +21,13 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined); const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function toKebabCase(str: string) { function toKebabCase(str: string) {
return str.replace(/[A-Z]/g, m => '-' + m.toLowerCase()); return str.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
} }
function applyCustomThemeVars(theme: Theme) { function applyCustomThemeVars(theme: Theme) {
const root = document.documentElement; const root = document.documentElement;
for (const [key, value] of Object.entries(theme)) { for (const [key, value] of Object.entries(theme)) {
if (key === 'name') continue; if (key === "name") continue;
root.style.setProperty(`--color-${toKebabCase(key)}`, value); root.style.setProperty(`--color-${toKebabCase(key)}`, value);
} }
} }
@ -29,64 +38,91 @@ function clearCustomThemeVars() {
} }
} }
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) => { const setCustomTheme = useCallback((customTheme: Theme) => {
localStorage.setItem('custom-theme', JSON.stringify(customTheme)); localStorage.setItem("custom-theme", JSON.stringify(customTheme));
applyCustomThemeVars(customTheme); applyCustomThemeVars(customTheme);
setTheme('custom'); setThemeName("custom");
localStorage.setItem("theme", "custom");
setCurrentTheme(customTheme);
}, []); }, []);
const getCustomTheme = (): Theme | undefined => { const getCustomTheme = (): Theme | undefined => {
const themeStr = localStorage.getItem('custom-theme'); return getStoredCustomTheme();
if (!themeStr) { };
return undefined
}
try {
let theme = JSON.parse(themeStr) as Theme
return theme
} catch (err) {
return undefined
}
}
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
root.setAttribute('data-theme', theme); root.setAttribute("data-theme", themeName);
localStorage.setItem('theme', theme)
console.log(theme)
if (theme === 'custom') { if (themeName === "custom") {
const saved = localStorage.getItem('custom-theme'); applyCustomThemeVars(currentTheme);
if (saved) {
try {
const parsed = JSON.parse(saved) as Theme;
applyCustomThemeVars(parsed);
} catch (err) {
console.error('Invalid custom theme in localStorage', err);
}
} else {
setTheme('yuu')
}
} else { } else {
clearCustomThemeVars() clearCustomThemeVars();
} }
}, [theme]); }, [themeName, currentTheme]);
return ( return (
<ThemeContext.Provider value={{ theme, setTheme, setCustomTheme, getCustomTheme }}> <ThemeContext.Provider
value={{
themeName,
theme: currentTheme,
setTheme,
resetTheme,
setCustomTheme,
getCustomTheme,
}}
>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
); );

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

@ -47,6 +47,7 @@ export default function Track() {
<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,7 +2,6 @@ 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
@ -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,8 +135,7 @@ 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",
@ -160,8 +151,7 @@ export const themes: Theme[] = [
success: "#28A745", success: "#28A745",
info: "#17A2B8", info: "#17A2B8",
}, },
{ asuka: {
name: "asuka",
bg: "#3B1212", bg: "#3B1212",
bgSecondary: "#471B1B", bgSecondary: "#471B1B",
bgTertiary: "#020202", bgTertiary: "#020202",
@ -177,8 +167,7 @@ export const themes: Theme[] = [
success: "#32CD32", success: "#32CD32",
info: "#1E90FF", info: "#1E90FF",
}, },
{ urim: {
name: "urim",
bg: "#101713", bg: "#101713",
bgSecondary: "#1B2921", bgSecondary: "#1B2921",
bgTertiary: "#273B30", bgTertiary: "#273B30",
@ -194,8 +183,7 @@ export const themes: Theme[] = [
success: "#28A745", success: "#28A745",
info: "#17A2B8", info: "#17A2B8",
}, },
{ match: {
name: "match",
bg: "#071014", bg: "#071014",
bgSecondary: "#0A181E", bgSecondary: "#0A181E",
bgTertiary: "#112A34", bgTertiary: "#112A34",
@ -211,8 +199,7 @@ export const themes: Theme[] = [
success: "#28A745", success: "#28A745",
info: "#17A2B8", info: "#17A2B8",
}, },
{ lemon: {
name: "lemon",
bg: "#1a171a", bg: "#1a171a",
bgSecondary: "#2E272E", bgSecondary: "#2E272E",
bgTertiary: "#443844", bgTertiary: "#443844",
@ -228,12 +215,12 @@ export const themes: Theme[] = [
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.

@ -110,6 +110,7 @@ func Run(
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"
) )
@ -58,6 +60,7 @@ type SubmitListenOpts struct {
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"
@ -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
} }
@ -134,6 +140,7 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
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)

@ -14,12 +14,15 @@ import (
type ImageSource struct { type ImageSource struct {
deezerEnabled bool deezerEnabled bool
deezerC *DeezerClient deezerC *DeezerClient
subsonicEnabled bool
subsonicC *SubsonicClient
caaEnabled bool 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