Compare commits

..

No commits in common. 'a4689bed2736e58e5d5c607dd9a2aee705a167b8' and '70f51987813bb85ac01db6f8b0b43804916ea2bc' have entirely different histories.

@ -101,23 +101,6 @@ function logout(): Promise<Response> {
}) })
} }
function getCfg(): Promise<Config> {
return fetch(`/apis/web/v1/config`).then(r => r.json() as Promise<Config>)
}
function submitListen(id: string, ts: Date): Promise<Response> {
const form = new URLSearchParams
form.append("track_id", id)
const ms = new Date(ts).getTime()
const unix= Math.floor(ms / 1000);
form.append("unix", unix.toString())
return fetch(`/apis/web/v1/listen`, {
method: "POST",
body: form,
})
}
function getApiKeys(): Promise<ApiKey[]> { function getApiKeys(): Promise<ApiKey[]> {
return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>) return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>)
} }
@ -231,7 +214,6 @@ export {
imageUrl, imageUrl,
login, login,
logout, logout,
getCfg,
deleteItem, deleteItem,
updateUser, updateUser,
getAliases, getAliases,
@ -245,7 +227,6 @@ export {
deleteListen, deleteListen,
getAlbum, getAlbum,
getExport, getExport,
submitListen,
} }
type Track = { type Track = {
id: number id: number
@ -256,7 +237,6 @@ type Track = {
album_id: number album_id: number
musicbrainz_id: string musicbrainz_id: string
time_listened: number time_listened: number
first_listen: number
} }
type Artist = { type Artist = {
id: number id: number
@ -266,7 +246,6 @@ type Artist = {
listen_count: number listen_count: number
musicbrainz_id: string musicbrainz_id: string
time_listened: number time_listened: number
first_listen: number
is_primary: boolean is_primary: boolean
} }
type Album = { type Album = {
@ -278,7 +257,6 @@ type Album = {
artists: SimpleArtists[] artists: SimpleArtists[]
musicbrainz_id: string musicbrainz_id: string
time_listened: number time_listened: number
first_listen: number
} }
type Alias = { type Alias = {
id: number id: number
@ -331,9 +309,6 @@ type ApiKey = {
type ApiError = { type ApiError = {
error: string error: string
} }
type Config = {
default_theme: string
}
export type { export type {
getItemsArgs, getItemsArgs,
@ -348,6 +323,5 @@ export type {
User, User,
Alias, Alias,
ApiKey, ApiKey,
ApiError, ApiError
Config
} }

@ -1,8 +1,8 @@
import { useEffect, useState } from "react" import { useState } from "react"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { timeSince } from "~/utils/utils" import { timeSince } from "~/utils/utils"
import ArtistLinks from "./ArtistLinks" import ArtistLinks from "./ArtistLinks"
import { deleteListen, getLastListens, type getItemsArgs, type Listen, type Track } from "api/api" import { deleteListen, getLastListens, type getItemsArgs, type Listen } from "api/api"
import { Link } from "react-router" import { Link } from "react-router"
import { useAppContext } from "~/providers/AppProvider" import { useAppContext } from "~/providers/AppProvider"
@ -12,7 +12,6 @@ interface Props {
albumId?: Number albumId?: Number
trackId?: number trackId?: number
hideArtists?: boolean hideArtists?: boolean
showNowPlaying?: boolean
} }
export default function LastPlays(props: Props) { export default function LastPlays(props: Props) {
@ -28,20 +27,7 @@ export default function LastPlays(props: Props) {
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
}) })
const [items, setItems] = useState<Listen[] | null>(null) const [items, setItems] = useState<Listen[] | null>(null)
const [nowPlaying, setNowPlaying] = useState<Track | undefined>(undefined)
useEffect(() => {
fetch('/apis/web/v1/now-playing')
.then(r => r.json())
.then(r => {
console.log(r)
if (r.currently_playing) {
setNowPlaying(r.track)
}
})
}, [])
const handleDelete = async (listen: Listen) => { const handleDelete = async (listen: Listen) => {
if (!data) return if (!data) return
@ -83,30 +69,6 @@ export default function LastPlays(props: Props) {
</h2> </h2>
<table className="-ml-4"> <table className="-ml-4">
<tbody> <tbody>
{props.showNowPlaying && nowPlaying &&
<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={nowPlaying.artists} /> {' '}
</>
)}
<Link
className="hover:text-[--color-fg-secondary]"
to={`/track/${nowPlaying.id}`}
>
{nowPlaying.title}
</Link>
</td>
</tr>
}
{listens.map((item) => ( {listens.map((item) => (
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]"> <tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
<td className="w-[18px] pr-2 align-middle" > <td className="w-[18px] pr-2 align-middle" >

@ -1,57 +0,0 @@
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,11 +1,12 @@
import { useState } from 'react'; // ThemeSwitcher.tsx
import { useEffect, useState } from 'react';
import { useTheme } from '../../hooks/useTheme'; import { useTheme } from '../../hooks/useTheme';
import themes from '~/styles/themes.css'; import themes from '~/styles/themes.css';
import ThemeOption from './ThemeOption'; import ThemeOption from './ThemeOption';
import { AsyncButton } from '../AsyncButton'; import { AsyncButton } from '../AsyncButton';
export function ThemeSwitcher() { export function ThemeSwitcher() {
const { setTheme } = useTheme(); const { theme, themeName, setTheme } = useTheme();
const initialTheme = { const initialTheme = {
bg: "#1e1816", bg: "#1e1816",
bgSecondary: "#2f2623", bgSecondary: "#2f2623",
@ -23,7 +24,7 @@ export function ThemeSwitcher() {
info: "#87b8dd", info: "#87b8dd",
} }
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme() const { setCustomTheme, getCustomTheme } = useTheme()
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")) const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " "))
const handleCustomTheme = () => { const handleCustomTheme = () => {
@ -41,12 +42,7 @@ export function ThemeSwitcher() {
return ( return (
<div className='flex flex-col gap-10'> <div className='flex flex-col gap-10'>
<div> <div>
<div className='flex items-center gap-3'>
<h2>Select Theme</h2> <h2>Select Theme</h2>
<div className='mb-3'>
<AsyncButton onClick={resetTheme}>Reset</AsyncButton>
</div>
</div>
<div className="grid grid-cols-2 items-center gap-2"> <div className="grid grid-cols-2 items-center gap-2">
{Object.entries(themes).map(([name, themeData]) => ( {Object.entries(themes).map(([name, themeData]) => (
<ThemeOption setTheme={setTheme} key={name} theme={themeData} themeName={name} /> <ThemeOption setTheme={setTheme} key={name} theme={themeData} themeName={name} />

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

@ -1,13 +1,11 @@
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react'; import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react';
import { type Theme, themes } from '~/styles/themes.css'; import { type Theme, themes } from '~/styles/themes.css';
import { themeVars } from '~/styles/vars.css'; import { themeVars } from '~/styles/vars.css';
import { useAppContext } from './AppProvider';
interface ThemeContextValue { interface ThemeContextValue {
themeName: string; themeName: string;
theme: Theme; theme: Theme;
setTheme: (theme: string) => void; setTheme: (theme: string) => void;
resetTheme: () => void;
setCustomTheme: (theme: Theme) => void; setCustomTheme: (theme: Theme) => void;
getCustomTheme: () => Theme | undefined; getCustomTheme: () => Theme | undefined;
} }
@ -45,19 +43,19 @@ function getStoredCustomTheme(): Theme | undefined {
} }
export function ThemeProvider({ export function ThemeProvider({
theme: initialTheme,
children, children,
}: { }: {
theme: string;
children: ReactNode; children: ReactNode;
}) { }) {
let defaultTheme = useAppContext().defaultTheme
let initialTheme = localStorage.getItem("theme") ?? defaultTheme
const [themeName, setThemeName] = useState(initialTheme); const [themeName, setThemeName] = useState(initialTheme);
const [currentTheme, setCurrentTheme] = useState<Theme>(() => { const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
if (initialTheme === 'custom') { if (initialTheme === 'custom') {
const customTheme = getStoredCustomTheme(); const customTheme = getStoredCustomTheme();
return customTheme || themes[defaultTheme]; return customTheme || themes.yuu;
} }
return themes[initialTheme] || themes[defaultTheme]; return themes[initialTheme] || themes.yuu;
}); });
const setTheme = (newThemeName: string) => { const setTheme = (newThemeName: string) => {
@ -68,29 +66,21 @@ export function ThemeProvider({
setCurrentTheme(customTheme); setCurrentTheme(customTheme);
} else { } else {
// Fallback to default theme if no custom theme found // Fallback to default theme if no custom theme found
setThemeName(defaultTheme); setThemeName('yuu');
setCurrentTheme(themes[defaultTheme]); setCurrentTheme(themes.yuu);
} }
} else { } else {
const foundTheme = themes[newThemeName]; const foundTheme = themes[newThemeName];
if (foundTheme) { if (foundTheme) {
localStorage.setItem('theme', newThemeName)
setCurrentTheme(foundTheme); setCurrentTheme(foundTheme);
} }
} }
} }
const resetTheme = () => {
setThemeName(defaultTheme)
localStorage.removeItem('theme')
setCurrentTheme(themes[defaultTheme])
}
const setCustomTheme = useCallback((customTheme: Theme) => { const setCustomTheme = useCallback((customTheme: Theme) => {
localStorage.setItem('custom-theme', JSON.stringify(customTheme)); localStorage.setItem('custom-theme', JSON.stringify(customTheme));
applyCustomThemeVars(customTheme); applyCustomThemeVars(customTheme);
setThemeName('custom'); setThemeName('custom');
localStorage.setItem('theme', 'custom')
setCurrentTheme(customTheme); setCurrentTheme(customTheme);
}, []); }, []);
@ -102,6 +92,7 @@ export function ThemeProvider({
const root = document.documentElement; const root = document.documentElement;
root.setAttribute('data-theme', themeName); root.setAttribute('data-theme', themeName);
localStorage.setItem('theme', themeName);
if (themeName === 'custom') { if (themeName === 'custom') {
applyCustomThemeVars(currentTheme); applyCustomThemeVars(currentTheme);
@ -111,14 +102,7 @@ export function ThemeProvider({
}, [themeName, currentTheme]); }, [themeName, currentTheme]);
return ( return (
<ThemeContext.Provider value={{ <ThemeContext.Provider value={{ themeName, theme: currentTheme, setTheme, setCustomTheme, getCustomTheme }}>
themeName,
theme: currentTheme,
setTheme,
resetTheme,
setCustomTheme,
getCustomTheme
}}>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
); );

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

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

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

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

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

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

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

@ -32,5 +32,4 @@ Once the relay is configured, Koito will automatically forward any requests it r
:::note :::note
Be sure to include the full path to the ListenBrainz endpoint of the server you are relaying to in the `KOITO_LBZ_RELAY_URL`. Be sure to include the full path to the ListenBrainz endpoint of the server you are relaying to in the `KOITO_LBZ_RELAY_URL`.
For example, to relay to the main ListenBrainz instance, you would set `KOITO_ENABLE_LBZ_RELAY` to `https://api.listenbrainz.org/1`.
::: :::

@ -23,9 +23,6 @@ If the environment variable is defined without **and** with the suffix at the sa
##### KOITO_DEFAULT_PASSWORD ##### KOITO_DEFAULT_PASSWORD
- Default: `changeme` - Default: `changeme`
- Description: The password for the user that is created on first startup. Only applies when running Koito for the first time. - Description: The password for the user that is created on first startup. Only applies when running Koito for the first time.
##### KOITO_DEFAULT_THEME
- Default: `yuu`
- Description: The lowercase name of the default theme to be used by the client. Overridden if a user picks a theme in the theme switcher.
##### KOITO_BIND_ADDR ##### KOITO_BIND_ADDR
- Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`. - Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`.
##### KOITO_LISTEN_PORT ##### KOITO_LISTEN_PORT

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

@ -1,77 +0,0 @@
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)
}
}

@ -1,41 +0,0 @@
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})
}
}
}
}

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

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

@ -8,14 +8,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/mbz" "github.com/gabehf/koito/internal/mbz"
"github.com/gabehf/koito/internal/memkv"
"github.com/gabehf/koito/internal/models" "github.com/gabehf/koito/internal/models"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -60,7 +58,6 @@ type SubmitListenOpts struct {
UserID int32 UserID int32
Client string Client string
IsNowPlaying bool
} }
const ( const (
@ -168,14 +165,6 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
} }
} }
if opts.IsNowPlaying {
if track.Duration == 0 {
memkv.Store.Set(strconv.Itoa(int(opts.UserID)), track.ID)
} else {
memkv.Store.Set(strconv.Itoa(int(opts.UserID)), track.ID, time.Duration(track.Duration)*time.Second)
}
}
if opts.SkipSaveListen { if opts.SkipSaveListen {
return nil return nil
} }

@ -31,7 +31,6 @@ const (
CONFIG_DIR_ENV = "KOITO_CONFIG_DIR" CONFIG_DIR_ENV = "KOITO_CONFIG_DIR"
DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME" DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME"
DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD" DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD"
DEFAULT_THEME_ENV = "KOITO_DEFAULT_THEME"
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER" DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE" DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
@ -61,7 +60,6 @@ type config struct {
lbzRelayToken string lbzRelayToken string
defaultPw string defaultPw string
defaultUsername string defaultUsername string
defaultTheme string
disableDeezer bool disableDeezer bool
disableCAA bool disableCAA bool
disableMusicBrainz bool disableMusicBrainz bool
@ -164,8 +162,6 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
cfg.defaultPw = getenv(DEFAULT_PASSWORD_ENV) cfg.defaultPw = getenv(DEFAULT_PASSWORD_ENV)
} }
cfg.defaultTheme = getenv(DEFAULT_THEME_ENV)
cfg.configDir = getenv(CONFIG_DIR_ENV) cfg.configDir = getenv(CONFIG_DIR_ENV)
if cfg.configDir == "" { if cfg.configDir == "" {
cfg.configDir = "/etc/koito" cfg.configDir = "/etc/koito"
@ -281,12 +277,6 @@ func DefaultUsername() string {
return globalConfig.defaultUsername return globalConfig.defaultUsername
} }
func DefaultTheme() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.defaultTheme
}
func FullImageCacheEnabled() bool { func FullImageCacheEnabled() bool {
lock.RLock() lock.RLock()
defer lock.RUnlock() defer lock.RUnlock()

@ -98,14 +98,8 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err) return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err)
} }
firstListen, err := d.q.GetFirstListenFromRelease(ctx, ret.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err)
}
ret.ListenCount = count ret.ListenCount = count
ret.TimeListened = seconds ret.TimeListened = seconds
ret.FirstListen = firstListen.ListenedAt.Unix()
return ret, nil return ret, nil
} }

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

@ -89,14 +89,8 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err) return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err)
} }
firstListen, err := d.q.GetFirstListenFromTrack(ctx, track.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err)
}
track.ListenCount = count track.ListenCount = count
track.TimeListened = seconds track.TimeListened = seconds
track.FirstListen = firstListen.ListenedAt.Unix()
return &track, nil return &track, nil
} }

@ -1,110 +0,0 @@
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,7 +11,6 @@ type Album struct {
VariousArtists bool `json:"is_various_artists"` VariousArtists bool `json:"is_various_artists"`
ListenCount int64 `json:"listen_count"` ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"` TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
} }
// type SimpleAlbum struct { // type SimpleAlbum struct {

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

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

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

Loading…
Cancel
Save