Compare commits

..

2 Commits

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

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

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

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

@ -43,7 +43,8 @@ export default function ActivityOptsSelector({
useEffect(() => {
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);
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
if (cachedStep) stepSetter(cachedStep);

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

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

@ -1,19 +1,20 @@
import type { Theme } from "~/providers/ThemeProvider";
import type { Theme } from "~/styles/themes.css";
interface Props {
theme: Theme
themeName: string
setTheme: Function
}
export default function ThemeOption({ theme, setTheme }: Props) {
export default function ThemeOption({ theme, themeName, setTheme }: Props) {
const capitalizeFirstLetter = (s: string) => {
return s.charAt(0).toUpperCase() + s.slice(1);
}
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 className="text-xs sm:text-sm">{capitalizeFirstLetter(theme.name)}</div>
<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(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.fgSecondary}}></div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>

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

@ -1,10 +1,11 @@
import type { User } from "api/api";
import { getCfg, type User } from "api/api";
import { createContext, useContext, useEffect, useState } from "react";
interface AppContextType {
user: User | null | undefined;
configurableHomeActivity: boolean;
homeItems: number;
defaultTheme: string;
setConfigurableHomeActivity: (value: boolean) => void;
setHomeItems: (value: number) => void;
setUsername: (value: string) => void;
@ -22,15 +23,19 @@ export const useAppContext = () => {
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
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 setUsername = (value: string) => {
if (!user) {
return
return;
}
setUser({...user, username: value})
}
setUser({ ...user, username: value });
};
useEffect(() => {
fetch("/apis/web/v1/user/me")
@ -42,9 +47,19 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
setConfigurableHomeActivity(true);
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;
}
@ -52,10 +67,13 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
user,
configurableHomeActivity,
homeItems,
defaultTheme,
setConfigurableHomeActivity,
setHomeItems,
setUsername,
};
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
};
return (
<AppContext.Provider value={contextValue}>{children}</AppContext.Provider>
);
};

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

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

@ -33,7 +33,7 @@ export default function Home() {
<TopArtists period={period} limit={homeItems} />
<TopAlbums 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>
</main>

@ -44,6 +44,7 @@ export default function Album() {
subContent={<div className="flex flex-col gap-2 items-start">
{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={new Date(album.first_listen * 1000).toLocaleString()}>Listening since {new Date(album.first_listen * 1000).toLocaleDateString()}</p>}
</div>}
>
<div className="mt-10">

@ -50,6 +50,7 @@ export default function Artist() {
subContent={<div className="flex flex-col gap-2 items-start">
{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={new Date(artist.first_listen * 1000).toLocaleString()}>Listening since {new Date(artist.first_listen * 1000).toLocaleDateString()}</p>}
</div>}
>
<div className="mt-10">

@ -2,13 +2,14 @@ import React, { useEffect, useState } from "react";
import { average } from "color.js";
import { imageUrl, type SearchResponse } from "api/api";
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 MergeModal from "~/components/modals/MergeModal";
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
import DeleteModal from "~/components/modals/DeleteModal";
import RenameModal 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 MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
@ -32,6 +33,7 @@ export default function MediaLayout(props: Props) {
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [imageModalOpen, setImageModalOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [addListenModalOpen, setAddListenModalOpen] = useState(false);
const { user } = useAppContext();
useEffect(() => {
@ -80,6 +82,12 @@ export default function MediaLayout(props: Props) {
</div>
{ user &&
<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="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>

@ -46,7 +46,8 @@ export default function Track() {
subContent={<div className="flex flex-col gap-2 items-start">
<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>}
{<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 className="mt-10">

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

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

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

@ -29,6 +29,16 @@ WHERE at.artist_id = $5
ORDER BY l.listened_at DESC
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
SELECT
l.*,
@ -42,6 +52,15 @@ WHERE l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC
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
SELECT
l.*,
@ -55,6 +74,15 @@ WHERE l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC
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
SELECT COUNT(*) AS total_count
FROM listens l

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

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

@ -23,6 +23,9 @@ If the environment variable is defined without **and** with the suffix at the sa
##### KOITO_DEFAULT_PASSWORD
- Default: `changeme`
- 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
- Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`.
##### KOITO_LISTEN_PORT
@ -37,6 +40,9 @@ If the environment variable is defined without **and** with the suffix at the sa
##### KOITO_LOG_LEVEL
- Default: `info`
- 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
- Default: `https://musicbrainz.org`
- 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.
##### KOITO_DISABLE_MUSICBRAINZ
- 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
- Default: `false`
- Description: Skips running the importer on startup.
@ -81,4 +93,4 @@ If the environment variable is defined without **and** with the suffix at the sa
- Description: When true, images will be downloaded and cached during imports.
##### KOITO_CORS_ALLOWED_ORIGINS
- Default: No CORS policy
- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins.
- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins.

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

@ -211,10 +211,8 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
Time: listenedAt,
UserID: u.ID,
Client: client,
}
if req.ListenType == ListenTypePlayingNow {
opts.SkipSaveListen = true
IsNowPlaying: req.ListenType == ListenTypePlayingNow,
SkipSaveListen: req.ListenType == ListenTypePlayingNow,
}
_, 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"
"os"
"path"
"strconv"
"strings"
"sync"
"testing"
@ -890,3 +891,86 @@ func TestSetPrimaryArtist(t *testing.T) {
require.NoError(t, err)
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))
r.Route("/apis/web/v1", func(r chi.Router) {
r.Get("/config", handlers.GetCfgHandler())
r.Get("/artist", handlers.GetArtistHandler(db))
r.Get("/artists", handlers.GetArtistsForItemHandler(db))
r.Get("/album", handlers.GetAlbumHandler(db))
@ -44,6 +45,7 @@ func bindRoutes(
r.Get("/top-artists", handlers.GetTopArtistsHandler(db))
r.Get("/listens", handlers.GetListensHandler(db))
r.Get("/listen-activity", handlers.GetListenActivityHandler(db))
r.Get("/now-playing", handlers.NowPlayingHandler(db))
r.Get("/stats", handlers.StatsHandler(db))
r.Get("/search", handlers.SearchHandler(db))
r.Get("/aliases", handlers.GetAliasesHandler(db))
@ -80,6 +82,7 @@ func bindRoutes(
r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db))
r.Delete("/album", handlers.DeleteAlbumHandler(db))
r.Delete("/track", handlers.DeleteTrackHandler(db))
r.Post("/listen", handlers.SubmitListenWithIDHandler(db))
r.Delete("/listen", handlers.DeleteListenHandler(db))
r.Post("/aliases", handlers.CreateAliasHandler(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 {
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)
fallbackMatches, err := matchArtistsByNames(ctx, allArtists, nil, d, opts)
if err != nil {
@ -180,7 +180,7 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts,
}
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)

@ -8,12 +8,14 @@ import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/mbz"
"github.com/gabehf/koito/internal/memkv"
"github.com/gabehf/koito/internal/models"
"github.com/google/uuid"
)
@ -56,8 +58,9 @@ type SubmitListenOpts struct {
ReleaseGroupMbzID uuid.UUID
Time time.Time
UserID int32
Client string
UserID int32
Client string
IsNowPlaying bool
}
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 {
return nil
}
@ -190,21 +201,18 @@ func buildArtistStr(artists []*models.Artist) string {
var (
// Bracketed feat patterns
bracketFeatPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\(feat\. ([^)]*)\)`),
regexp.MustCompile(`(?i)\[feat\. ([^\]]*)\]`),
regexp.MustCompile(`(?i)\([fF]eat\. ([^)]*)\)`),
regexp.MustCompile(`(?i)\[[fF]eat\. ([^\]]*)\]`),
}
// Inline feat (not in brackets)
inlineFeatPattern = regexp.MustCompile(`(?i)feat\. ([^()\[\]]+)$`)
inlineFeatPattern = regexp.MustCompile(`(?i)[fF]eat\. ([^()\[\]]+)$`)
// Delimiters only used inside feat. sections
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
func ParseArtists(artist string, title string) []string {
func ParseArtists(artist string, title string, addlSeparators []*regexp.Regexp) []string {
seen := make(map[string]struct{})
var out []string
@ -219,12 +227,9 @@ func ParseArtists(artist string, title string) []string {
}
}
foundFeat := false
// Extract bracketed features from artist
for _, re := range bracketFeatPatterns {
if matches := re.FindStringSubmatch(artist); matches != nil {
foundFeat = true
artist = strings.Replace(artist, matches[0], "", 1)
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
@ -233,7 +238,6 @@ func ParseArtists(artist string, title string) []string {
}
// Extract inline feat. from artist
if matches := inlineFeatPattern.FindStringSubmatch(artist); matches != nil {
foundFeat = true
artist = strings.Replace(artist, matches[0], "", 1)
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
@ -241,14 +245,19 @@ func ParseArtists(artist string, title string) []string {
}
// Add base artist(s)
if foundFeat {
add(strings.TrimSpace(artist))
} else {
// Only split on " · " in base artist string
for _, name := range mainArtistDotSplitter.Split(artist, -1) {
l1 := len(out)
for _, re := range addlSeparators {
for _, name := range re.Split(artist, -1) {
if name == artist {
continue
}
add(name)
}
}
// Only add the full artist string if no splitters were matched
if l1 == len(out) {
add(artist)
}
// Extract features from title
for _, re := range bracketFeatPatterns {

@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"regexp"
"testing"
"time"
@ -167,15 +168,15 @@ func getTestGetenv(resource *dockertest.Resource) func(string) string {
func truncateTestData(t *testing.T) {
err := store.Exec(context.Background(),
`TRUNCATE
artists,
`TRUNCATE
artists,
artist_aliases,
tracks,
artist_tracks,
releases,
artist_releases,
tracks,
artist_tracks,
releases,
artist_releases,
release_aliases,
listens
listens
RESTART IDENTITY CASCADE`)
require.NoError(t, err)
}
@ -184,23 +185,23 @@ func setupTestDataWithMbzIDs(t *testing.T) {
truncateTestData(t)
err := store.Exec(context.Background(),
`INSERT INTO artists (musicbrainz_id)
`INSERT INTO artists (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000001')`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO releases (musicbrainz_id)
`INSERT INTO releases (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000101')`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'AG! Calling', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_releases (artist_id, release_id)
`INSERT INTO artist_releases (artist_id, release_id)
VALUES (1, 1)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
@ -221,23 +222,23 @@ func setupTestDataSansMbzIDs(t *testing.T) {
truncateTestData(t)
err := store.Exec(context.Background(),
`INSERT INTO artists (musicbrainz_id)
`INSERT INTO artists (musicbrainz_id)
VALUES (NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO releases (musicbrainz_id)
`INSERT INTO releases (musicbrainz_id)
VALUES (NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'AG! Calling', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_releases (artist_id, release_id)
`INSERT INTO artist_releases (artist_id, release_id)
VALUES (1, 1)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
@ -358,10 +359,16 @@ func TestArtistStringParse(t *testing.T) {
// artists in both
{"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"},
{"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 {
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)
}
}

@ -203,6 +203,22 @@ func TestSubmitListen_CreateAllNoMbzIDsNoArtistNamesNoReleaseTitle(t *testing.T)
)`, "Madeline Kenney")
require.NoError(t, err)
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) {

@ -3,6 +3,7 @@ package cfg
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
@ -31,9 +32,12 @@ const (
CONFIG_DIR_ENV = "KOITO_CONFIG_DIR"
DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME"
DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD"
DEFAULT_THEME_ENV = "KOITO_DEFAULT_THEME"
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
SUBSONIC_URL_ENV = "KOITO_SUBSONIC_URL"
SUBSONIC_PARAMS_ENV = "KOITO_SUBSONIC_PARAMS"
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
@ -42,6 +46,7 @@ const (
IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX"
IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX"
FETCH_IMAGES_DURING_IMPORT_ENV = "KOITO_FETCH_IMAGES_DURING_IMPORT"
ARTIST_SEPARATORS_ENV = "KOITO_ARTIST_SEPARATORS_REGEX"
)
type config struct {
@ -60,9 +65,13 @@ type config struct {
lbzRelayToken string
defaultPw string
defaultUsername string
defaultTheme string
disableDeezer bool
disableCAA bool
disableMusicBrainz bool
subsonicUrl string
subsonicParams string
subsonicEnabled bool
skipImport bool
fetchImageDuringImport bool
allowedHosts []string
@ -73,6 +82,7 @@ type config struct {
userAgent string
importBefore time.Time
importAfter time.Time
artistSeparators []*regexp.Regexp
}
var (
@ -147,6 +157,12 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV))
cfg.disableCAA = parseBool(getenv(DISABLE_COVER_ART_ARCHIVE_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.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.defaultTheme = getenv(DEFAULT_THEME_ENV)
cfg.configDir = getenv(CONFIG_DIR_ENV)
if cfg.configDir == "" {
cfg.configDir = "/etc/koito"
@ -174,6 +192,18 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
rawCors := getenv(CORS_ORIGINS_ENV)
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)) {
case "debug":
cfg.logLevel = 0
@ -277,6 +307,12 @@ func DefaultUsername() string {
return globalConfig.defaultUsername
}
func DefaultTheme() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.defaultTheme
}
func FullImageCacheEnabled() bool {
lock.RLock()
defer lock.RUnlock()
@ -301,6 +337,24 @@ func MusicBrainzDisabled() bool {
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 {
lock.RLock()
defer lock.RUnlock()
@ -349,3 +403,9 @@ func FetchImagesDuringImport() bool {
defer lock.RUnlock()
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)
}
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.TimeListened = seconds
ret.FirstListen = firstListen.ListenedAt.Unix()
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{
ArtistID: artistId,
ReleaseID: r.ID,
IsPrimary: opts.ArtistIDs[0] == artistId,
})
if err != nil {
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 {
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{
ID: row.ID,
MbzID: row.MusicBrainzID,
@ -49,6 +53,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
Image: row.Image,
ListenCount: count,
TimeListened: seconds,
FirstListen: firstListen.ListenedAt.Unix(),
}, nil
} else if opts.MusicBrainzID != uuid.Nil {
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 {
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{
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
TimeListened: seconds,
ListenCount: count,
TimeListened: seconds,
FirstListen: firstListen.ListenedAt.Unix(),
}, nil
} else if 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 {
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{
ID: row.ID,
MbzID: row.MusicBrainzID,
@ -109,6 +123,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
Image: row.Image,
ListenCount: count,
TimeListened: seconds,
FirstListen: firstListen.ListenedAt.Unix(),
}, nil
} else {
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)
}
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.TimeListened = seconds
track.FirstListen = firstListen.ListenedAt.Unix()
return &track, nil
}
@ -132,8 +138,9 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
// insert associated artists
for _, aid := range opts.ArtistIDs {
err = qtx.AssociateArtistToTrack(ctx, repository.AssociateArtistToTrackParams{
ArtistID: aid,
TrackID: trackRow.ID,
ArtistID: aid,
TrackID: trackRow.ID,
IsPrimary: opts.ArtistIDs[0] == aid,
})
if err != nil {
return nil, fmt.Errorf("SaveTrack: AssociateArtistToTrack: %w", err)

@ -12,14 +12,17 @@ import (
)
type ImageSource struct {
deezerEnabled bool
deezerC *DeezerClient
caaEnabled bool
deezerEnabled bool
deezerC *DeezerClient
subsonicEnabled bool
subsonicC *SubsonicClient
caaEnabled bool
}
type ImageSourceOpts struct {
UserAgent string
EnableCAA bool
EnableDeezer bool
UserAgent string
EnableCAA bool
EnableDeezer bool
EnableSubsonic bool
}
var once sync.Once
@ -48,6 +51,10 @@ func Initialize(opts ImageSourceOpts) {
imgsrc.deezerEnabled = true
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) {
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 {
img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases)
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) {
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 {
l.Debug().Msg("Attempting to find album image from CoverArtArchive")
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"`
ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
}
// type SimpleAlbum struct {

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

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

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

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

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

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

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: listen.sql
package repository
@ -190,6 +190,73 @@ func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) erro
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
SELECT
l.track_id, l.listened_at, l.client, l.user_id,

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

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: release.sql
package repository
@ -14,18 +14,19 @@ import (
)
const associateArtistToRelease = `-- name: AssociateArtistToRelease :exec
INSERT INTO artist_releases (artist_id, release_id)
VALUES ($1, $2)
INSERT INTO artist_releases (artist_id, release_id, is_primary)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
`
type AssociateArtistToReleaseParams struct {
ArtistID int32
ReleaseID int32
IsPrimary bool
}
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
}

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

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

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: track.sql
package repository
@ -13,18 +13,19 @@ import (
)
const associateArtistToTrack = `-- name: AssociateArtistToTrack :exec
INSERT INTO artist_tracks (artist_id, track_id)
VALUES ($1, $2)
INSERT INTO artist_tracks (artist_id, track_id, is_primary)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
`
type AssociateArtistToTrackParams struct {
ArtistID int32
TrackID int32
ArtistID int32
TrackID int32
IsPrimary bool
}
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
}

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

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

Loading…
Cancel
Save