feat: version v0.0.2

This commit is contained in:
Gabe Farrell 2025-06-14 19:14:30 -04:00
parent 0dceaf017a
commit 7ff317756f
36 changed files with 336 additions and 160 deletions

View file

@ -9,9 +9,11 @@
name: Publish Docker image name: Publish Docker image
on: on:
push: push:
branches: [main] branches: [main]
tags:
- 'v*'
jobs: jobs:
test: test:
@ -64,6 +66,10 @@ jobs:
with: with:
images: gabehf/koito 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 Docker image
id: push id: push
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
@ -71,7 +77,11 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
tags: gabehf/koito:latest tags: |
gabehf/koito:latest
gabehf/koito:${{ env.KOITO_VERSION }}
build-args: |
KOITO_VERSION=${{ env.KOITO_VERSION }}
- name: Generate artifact attestation - name: Generate artifact attestation
uses: actions/attest-build-provenance@v2 uses: actions/attest-build-provenance@v2

14
CHANGELOG.md Normal file
View file

@ -0,0 +1,14 @@
# v0.0.2
## Features
- Configurable CORS policy via KOITO_CORS_ALLOWED_ORIGINS
- A baseline mobile UI
## Enhancements
- The import source is now saved as the client for the imported listen.
## Fixes
- Account update form now works on enter key
## Updates
- Non-sensitive query parameters are logged with requests
- Koito version number is embedded through tags

View file

@ -1,15 +1,22 @@
FROM node AS frontend FROM node AS frontend
ARG KOITO_VERSION
ENV VITE_KOITO_VERSION=$KOITO_VERSION
ENV BUILD_TARGET=docker
WORKDIR /client WORKDIR /client
COPY ./client/package.json ./client/yarn.lock ./ COPY ./client/package.json ./client/yarn.lock ./
RUN yarn install RUN yarn install
COPY ./client . COPY ./client .
ENV BUILD_TARGET=docker
RUN yarn run build RUN yarn run build
FROM golang:1.23 AS backend FROM golang:1.23 AS backend
ARG KOITO_VERSION
ENV CGO_ENABLED=1
ENV GOOS=linux
WORKDIR /app WORKDIR /app
RUN apt-get update && \ RUN apt-get update && \
@ -21,7 +28,7 @@ RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o app ./cmd/api RUN go build -ldflags "-X main.Version=$KOITO_VERSION" -o app ./cmd/api
FROM debian:bookworm-slim AS final FROM debian:bookworm-slim AS final

View file

@ -55,7 +55,7 @@
:root { :root {
--header-xl: 78px; --header-xl: 36px;
--header-lg: 28px; --header-lg: 28px;
--header-md: 22px; --header-md: 22px;
--header-sm: 16px; --header-sm: 16px;
@ -63,6 +63,18 @@
--header-weight: 600; --header-weight: 600;
} }
@media (min-width: 60rem) {
:root {
--header-xl: 78px;
--header-lg: 28px;
--header-md: 22px;
--header-sm: 16px;
--header-xl-weight: 600;
--header-weight: 600;
}
}
html, html,
body { body {
background-color: var(--color-bg); background-color: var(--color-bg);

View file

@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { getActivity, type getActivityArgs } from "api/api" import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api"
import Popup from "./Popup" import Popup from "./Popup"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useTheme } from "~/hooks/useTheme" import { useTheme } from "~/hooks/useTheme"
@ -142,44 +142,55 @@ export default function ActivityGrid({
} }
} }
const dotSize = 12;
return ( const mobileDotSize = 10
<div className="flex flex-col items-start"> const normalDotSize = 12
<h2>Activity</h2>
{configurable ? let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
<ActivityOptsSelector rangeSetter={setRange} currentRange={rangeState} stepSetter={setStep} currentStep={stepState} />
: let dotSize = vw > 768 ? normalDotSize : mobileDotSize
return (<div className="flex flex-col items-start">
<h2>Activity</h2>
{configurable ? (
<ActivityOptsSelector
rangeSetter={setRange}
currentRange={rangeState}
stepSetter={setStep}
currentStep={stepState}
/>
) : (
'' ''
} )}
<div className="grid grid-flow-col grid-rows-7 gap-[5px]"> <div className="flex flex-row flex-wrap w-[94px] md:w-auto md:grid md:grid-flow-col md:grid-cols-7 md:grid-rows-7 gap-[4px] md:gap-[5px]">
{data.map((item) => ( {data.map((item) => (
<div <div
key={new Date(item.start_time).toString()} key={new Date(item.start_time).toString()}
style={{ width: dotSize, height: dotSize }} style={{ width: dotSize, height: dotSize }}
>
<Popup
position="top"
space={dotSize}
extraClasses="left-2"
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
> >
<Popup <div
position="top" style={{
space={dotSize} display: 'inline-block',
extraClasses="left-2" width: dotSize,
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`} height: dotSize,
> background:
<div item.listens > 0
style={{ ? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
width: dotSize, : 'var(--color-bg-secondary)',
height: dotSize, }}
display: 'inline-block', className={`rounded-[2px] md:rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`}
background: ></div>
item.listens > 0 </Popup>
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100)) </div>
: 'var(--color-bg-secondary)', ))}
}}
className={`rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`}
></div>
</Popup>
</div>
))}
</div>
</div> </div>
</div>
); );
} }

View file

@ -5,7 +5,7 @@ export default function Footer() {
return ( return (
<div className="mx-auto py-10 pt-20 color-fg-tertiary text-sm"> <div className="mx-auto py-10 pt-20 color-fg-tertiary text-sm">
<ul className="flex flex-col items-center w-sm justify-around"> <ul className="flex flex-col items-center w-sm justify-around">
<li>Koito {pkg.version}</li> <li>Koito {import.meta.env.VITE_KOITO_VERSION || pkg.version}</li>
<li><a href="https://github.com/gabehf/koito" target="_blank" className="link-underline">View the source on GitHub <ExternalLinkIcon className='inline mb-1' size={14}/></a></li> <li><a href="https://github.com/gabehf/koito" target="_blank" className="link-underline">View the source on GitHub <ExternalLinkIcon className='inline mb-1' size={14}/></a></li>
</ul> </ul>
</div> </div>

View file

@ -21,7 +21,7 @@ export default function LastPlays(props: Props) {
if (isPending) { if (isPending) {
return ( return (
<div className="w-[500px]"> <div className="w-[400px] sm:w-[500px]">
<h2>Last Played</h2> <h2>Last Played</h2>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
@ -43,8 +43,8 @@ export default function LastPlays(props: Props) {
<tbody> <tbody>
{data.items.map((item) => ( {data.items.map((item) => (
<tr key={`last_listen_${item.time}`}> <tr key={`last_listen_${item.time}`}>
<td className="color-fg-tertiary pr-4 text-sm" title={new Date(item.time).toString()}>{timeSince(new Date(item.time))}</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-[600px]"> <td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
{props.hideArtists ? <></> : <><ArtistLinks artists={item.track.artists} /> - </>} {props.hideArtists ? <></> : <><ArtistLinks artists={item.track.artists} /> - </>}
<Link className="hover:text-(--color-fg-secondary)" to={`/track/${item.track.id}`}>{item.track.title}</Link> <Link className="hover:text-(--color-fg-secondary)" to={`/track/${item.track.id}`}>{item.track.title}</Link>
</td> </td>

View file

@ -25,8 +25,10 @@ export default function Account() {
setLoading(false) setLoading(false)
} }
const updateHandler = () => { const updateHandler = () => {
setError('')
setSuccess('')
if (password != "" && confirmPw === "") { if (password != "" && confirmPw === "") {
setError("confirm your password before submitting") setError("confirm your new password before submitting")
return return
} }
setError('') setError('')
@ -58,37 +60,44 @@ export default function Account() {
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton> <AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
</div> </div>
<h2>Update User</h2> <h2>Update User</h2>
<div className="flex flex gap-4"> <form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
<input <div className="flex flex gap-4">
name="koito-update-username" <input
type="text" name="koito-update-username"
placeholder="Update username" type="text"
className="w-full mx-auto fg bg rounded p-2" placeholder="Update username"
value={username} className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setUsername(e.target.value)} value={username}
/> onChange={(e) => setUsername(e.target.value)}
</div> />
<div className="flex flex gap-4"> </div>
<input <div className="w-sm">
name="koito-update-password" <AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
type="password" </div>
placeholder="Update password" </form>
className="w-full mx-auto fg bg rounded p-2" <form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
value={password} <div className="flex flex gap-4">
onChange={(e) => setPassword(e.target.value)} <input
/> name="koito-update-password"
<input type="password"
name="koito-confirm-password" placeholder="Update password"
type="password" className="w-full mx-auto fg bg rounded p-2"
placeholder="Confirm new password" value={password}
className="w-full mx-auto fg bg rounded p-2" onChange={(e) => setPassword(e.target.value)}
value={confirmPw} />
onChange={(e) => setConfirmPw(e.target.value)} <input
/> name="koito-confirm-password"
</div> type="password"
<div className="w-sm"> placeholder="Confirm new password"
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton> className="w-full mx-auto fg bg rounded p-2"
</div> value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
</div>
</form>
{success != "" && <p className="success">{success}</p>} {success != "" && <p className="success">{success}</p>}
{error != "" && <p className="error">{error}</p>} {error != "" && <p className="error">{error}</p>}
</div> </div>

View file

@ -16,16 +16,25 @@ export default function SettingsModal({ open, setOpen } : Props) {
const { user } = useAppContext() const { user } = useAppContext()
const triggerClasses = "px-4 py-2 w-full hover-bg-secondary rounded-md text-start data-[state=active]:bg-[var(--color-bg-secondary)]" const triggerClasses = "px-4 py-2 w-full hover-bg-secondary rounded-md text-start data-[state=active]:bg-[var(--color-bg-secondary)]"
const contentClasses = "w-full px-10 overflow-y-auto" const contentClasses = "w-full px-2 mt-8 sm:mt-0 sm:px-10 overflow-y-auto"
return ( return (
<Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}> <Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
<Tabs defaultValue="Appearance" orientation="vertical" className="flex justify-between h-full"> <Tabs
<TabsList className="w-full flex flex-col gap-1 items-start max-w-1/4 rounded-md bg p-2 grow-0"> defaultValue="Appearance"
orientation="vertical" // still vertical, but layout is responsive via Tailwind
className="flex flex-col sm:flex-row h-full"
>
<TabsList className="flex flex-row sm:flex-col gap-1 w-full sm:max-w-1/4 rounded-md bg p-2">
<TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger> <TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger>
<TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger> <TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger>
{ user && <TabsTrigger className={triggerClasses} value="API Keys">API Keys</TabsTrigger>} {user && (
<TabsTrigger className={triggerClasses} value="API Keys">
API Keys
</TabsTrigger>
)}
</TabsList> </TabsList>
<TabsContent value="Account" className={contentClasses}> <TabsContent value="Account" className={contentClasses}>
<AccountPage /> <AccountPage />
</TabsContent> </TabsContent>

View file

@ -1,22 +1,36 @@
import { ExternalLink, Home, Info } from "lucide-react"; import { ExternalLink, Home, Info } from "lucide-react";
import SidebarSearch from "./SidebarSearch"; import SidebarSearch from "./SidebarSearch";
import SidebarItem from "./SidebarItem"; import SidebarItem from "./SidebarItem";
import SidebarSettings from "./SidebarSettings"; import SidebarSettings from "./SidebarSettings";
export default function Sidebar() { export default function Sidebar() {
const iconSize = 20; const iconSize = 20;
return ( return (
<div className="z-50 flex flex-col justify-between h-screen border-r-1 border-(--color-bg-tertiary) p-1 py-10 sticky left-0 top-0 bg-(--color-bg)"> <div className="overflow-x-hidden w-full sm:w-auto">
<div className="flex flex-col gap-4"> <div className="z-50 flex sm:flex-col justify-between sm:h-screen h-auto sm:w-auto w-full border-b sm:border-b-0 sm:border-r border-(--color-bg-tertiary) pt-2 sm:py-10 sm:px-1 px-4 sticky top-0 sm:left-0 bg-(--color-bg)">
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}><Home size={iconSize} /></SidebarItem> <div className="flex gap-4 sm:flex-col">
<SidebarSearch size={iconSize} /> <SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}>
</div> <Home size={iconSize} />
<div className="flex flex-col gap-4"> </SidebarItem>
<SidebarItem icon keyHint={<ExternalLink size={14} />} space={22} externalLink to="https://koito.io" name="About" onClick={() => {}} modal={<></>}><Info size={iconSize} /></SidebarItem> <SidebarSearch size={iconSize} />
<SidebarSettings size={iconSize} /> </div>
<div className="flex gap-4 sm:flex-col">
<SidebarItem
icon
keyHint={<ExternalLink size={14} />}
space={22}
externalLink
to="https://koito.io"
name="About"
onClick={() => {}}
modal={<></>}
>
<Info size={iconSize} />
</SidebarItem>
<SidebarSettings size={iconSize} />
</div>
</div> </div>
</div> </div>
); );
} }

View file

@ -12,8 +12,8 @@ export default function ThemeOption({ theme, setTheme }: Props) {
} }
return ( return (
<div onClick={() => setTheme(theme.name)} className="rounded-md p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}> <div onClick={() => setTheme(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}}>
{capitalizeFirstLetter(theme.name)} <div className="text-xs sm:text-sm">{capitalizeFirstLetter(theme.name)}</div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div> <div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div> <div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div> <div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>

View file

@ -65,12 +65,12 @@ export default function App() {
<AppProvider> <AppProvider>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<div className="flex"> <div className="flex-col flex sm:flex-row">
<Sidebar /> <Sidebar />
<div className="flex flex-col items-center mx-auto w-full"> <div className="flex flex-col items-center mx-auto w-full">
<Outlet /> <Outlet />
<Footer /> <Footer />
</div> </div>
</div> </div>
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider> </ThemeProvider>
@ -117,7 +117,7 @@ export function ErrorBoundary() {
<div className="w-full flex flex-col"> <div className="w-full flex flex-col">
<main className="pt-16 p-4 container mx-auto flex-grow"> <main className="pt-16 p-4 container mx-auto flex-grow">
<div className="flex gap-4 items-end"> <div className="flex gap-4 items-end">
<img className="w-[200px] rounded" src="../public/yuu.jpg" /> <img className="w-[200px] rounded" src="../yuu.jpg" />
<div> <div>
<h1>{message}</h1> <h1>{message}</h1>
<p>{details}</p> <p>{details}</p>

View file

@ -29,7 +29,7 @@ export default function Home() {
<ActivityGrid /> <ActivityGrid />
</div> </div>
<PeriodSelector setter={setPeriod} current={period} /> <PeriodSelector setter={setPeriod} current={period} />
<div className="flex flex-wrap 2xl:gap-20 xl:gap-10 justify-around gap-5"> <div className="flex flex-wrap 2xl:gap-20 xl:gap-10 justify-between mx-5 gap-5">
<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} />

View file

@ -47,7 +47,7 @@ export default function Album() {
<div className="mt-10"> <div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} /> <PeriodSelector setter={setPeriod} current={period} />
</div> </div>
<div className="flex gap-20 mt-10"> <div className="flex flex-wrap gap-20 mt-10">
<LastPlays limit={30} albumId={album.id} /> <LastPlays limit={30} albumId={album.id} />
<TopTracks limit={12} period={period} albumId={album.id} /> <TopTracks limit={12} period={period} albumId={album.id} />
<ActivityGrid autoAdjust configurable albumId={album.id} /> <ActivityGrid autoAdjust configurable albumId={album.id} />

View file

@ -45,6 +45,13 @@ export default function MediaLayout(props: Props) {
const title = `${props.title} - Koito` const title = `${props.title} - Koito`
const mobileIconSize = 22
const normalIconSize = 30
let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
let iconSize = vw > 768 ? normalIconSize : mobileIconSize
return ( return (
<main <main
className="w-full flex flex-col flex-grow" className="w-full flex flex-col flex-grow"
@ -61,19 +68,21 @@ export default function MediaLayout(props: Props) {
content={title} content={title}
/> />
<div className="w-19/20 mx-auto pt-12"> <div className="w-19/20 mx-auto pt-12">
<div className="flex gap-8 relative"> <div className="flex gap-8 flex-wrap relative">
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="w-sm shadow-(--color-shadow) shadow-lg" /> <div className="flex flex-col justify-around">
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="md:w-sm w-[220px] h-auto shadow-(--color-shadow) shadow-lg" />
</div>
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
<h3>{props.type}</h3> <h3>{props.type}</h3>
<h1>{props.title}</h1> <h1>{props.title}</h1>
{props.subContent} {props.subContent}
</div> </div>
{ user && { user &&
<div className="absolute right-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">
<button title="Rename Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={30} /></button> <button title="Rename 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={30} /></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={30} /></button> <button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={iconSize} /></button>
<button title="Delete Item" className="hover:cursor-pointer" onClick={() => setDeleteModalOpen(true)}><Trash size={30} /></button> <button title="Delete Item" className="hover:cursor-pointer" onClick={() => setDeleteModalOpen(true)}><Trash size={iconSize} /></button>
<RenameModal open={renameModalOpen} setOpen={setRenameModalOpen} type={props.type.toLowerCase()} id={props.id}/> <RenameModal open={renameModalOpen} setOpen={setRenameModalOpen} type={props.type.toLowerCase()} id={props.id}/>
<ImageReplaceModal open={imageModalOpen} setOpen={setImageModalOpen} id={props.imgItemId} musicbrainzId={props.musicbrainzId} type={props.type === "Track" ? "Album" : props.type} /> <ImageReplaceModal open={imageModalOpen} setOpen={setImageModalOpen} id={props.imgItemId} musicbrainzId={props.musicbrainzId} type={props.type === "Track" ? "Album" : props.type} />
<MergeModal currentTitle={props.title} mergeFunc={props.mergeFunc} mergeCleanerFunc={props.mergeCleanerFunc} type={props.type} currentId={props.id} open={mergeModalOpen} setOpen={setMergeModalOpen} /> <MergeModal currentTitle={props.title} mergeFunc={props.mergeFunc} mergeCleanerFunc={props.mergeCleanerFunc} type={props.type} currentId={props.id} open={mergeModalOpen} setOpen={setMergeModalOpen} />

View file

@ -50,9 +50,9 @@ export default function Track() {
<div className="mt-10"> <div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} /> <PeriodSelector setter={setPeriod} current={period} />
</div> </div>
<div className="flex gap-20 mt-10"> <div className="flex flex-wrap gap-20 mt-10">
<LastPlays limit={20} trackId={track.id}/> <LastPlays limit={20} trackId={track.id}/>
<ActivityGrid trackId={track.id} configurable autoAdjust /> <ActivityGrid trackId={track.id} configurable autoAdjust />
</div> </div>
</MediaLayout> </MediaLayout>
) )

View file

@ -1,6 +1,6 @@
{ {
"name": "koito", "name": "koito",
"version": "v0.0.1", "version": "dev",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View file

@ -14,7 +14,7 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
}, },
'/images': { '/images': {
target: 'http://192.168.0.153:4110', target: 'http://localhost:4110',
changeOrigin: true, changeOrigin: true,
} }
} }

View file

@ -45,6 +45,11 @@ durations will be filled in as you submit listens using the API.
First, create an export file using [this tool from ghan.nl](https://lastfm.ghan.nl/export/) in JSON format. Then, place the resulting file into the `import` folder in your config directory. First, create an export file using [this tool from ghan.nl](https://lastfm.ghan.nl/export/) in JSON format. Then, place the resulting file into the `import` folder in your config directory.
Once you restart Koito, it will automatically detect the file as a Last FM import, and begin adding your listen activity immediately. Once you restart Koito, it will automatically detect the file as a Last FM import, and begin adding your listen activity immediately.
:::note
LastFM exports do not include track duration information, which means that the 'Hours Listened' statistic may be incorrect after importing. However, track
durations will be filled in as you submit listens using the API.
:::
## ListenBrainz ## ListenBrainz
Create a ListenBrainz export file using [the export tool on the ListenBrainz website](https://listenbrainz.org/settings/export/). Then, place the resulting `.zip` file into the `import` Create a ListenBrainz export file using [the export tool on the ListenBrainz website](https://listenbrainz.org/settings/export/). Then, place the resulting `.zip` file into the `import`

View file

@ -34,8 +34,8 @@ services:
``` ```
Be sure to replace `secret_password` with a random password of your choice, and set `KOITO_ALLOWED_HOSTS` to include the domain name or IP address + port you will be accessing Koito Be sure to replace `secret_password` with a random password of your choice, and set `KOITO_ALLOWED_HOSTS` to include the domain name or IP address you will be accessing Koito
from when using either of the Docker methods described above. from.
Those are the two required environment variables. You can find a full list of configuration options in the [configuration reference](/reference/configuration). Those are the two required environment variables. You can find a full list of configuration options in the [configuration reference](/reference/configuration).

View file

@ -69,4 +69,7 @@ Koito is configured using **environment variables**. This is the full list of co
##### KOITO_IMPORT_BEFORE_UNIX ##### KOITO_IMPORT_BEFORE_UNIX
- Description: A unix timestamp. If an imported listen has a timestamp after this, it will be discarded. - Description: A unix timestamp. If an imported listen has a timestamp after this, it will be discarded.
##### KOITO_IMPORT_AFTER_UNIX ##### KOITO_IMPORT_AFTER_UNIX
- Description: A unix timestamp. If an imported listen has a timestamp before this, it will be discarded. - Description: A unix timestamp. If an imported listen has a timestamp before this, it will be discarded.
##### 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.

View file

@ -34,7 +34,7 @@ func Run(
w io.Writer, w io.Writer,
version string, version string,
) error { ) error {
err := cfg.Load(getenv) err := cfg.Load(getenv, version)
if err != nil { if err != nil {
panic("Engine: Failed to load configuration") panic("Engine: Failed to load configuration")
} }
@ -150,6 +150,12 @@ func Run(
l.Info().Msgf("Engine: Allowing hosts: %v", cfg.AllowedHosts()) l.Info().Msgf("Engine: Allowing hosts: %v", cfg.AllowedHosts())
} }
if len(cfg.AllowedOrigins()) == 0 || cfg.AllowedOrigins()[0] == "" {
l.Info().Msgf("Engine: Using default CORS policy")
} else {
l.Info().Msgf("Engine: CORS policy: Allowing origins: %v", cfg.AllowedOrigins())
}
l.Debug().Msg("Engine: Setting up HTTP server") l.Debug().Msg("Engine: Setting up HTTP server")
var ready atomic.Bool var ready atomic.Bool
mux := chi.NewRouter() mux := chi.NewRouter()
@ -157,6 +163,7 @@ func Run(
mux.Use(middleware.Logger(l)) mux.Use(middleware.Logger(l))
mux.Use(chimiddleware.Recoverer) mux.Use(chimiddleware.Recoverer)
mux.Use(chimiddleware.RealIP) mux.Use(chimiddleware.RealIP)
mux.Use(middleware.AllowedHosts)
bindRoutes(mux, &ready, store, mbzC) bindRoutes(mux, &ready, store, mbzC)
httpServer := &http.Server{ httpServer := &http.Server{

View file

@ -80,7 +80,7 @@ func TestMain(m *testing.M) {
} }
getenv := getTestGetenv(resource) getenv := getTestGetenv(resource)
err = cfg.Load(getenv) err = cfg.Load(getenv, "test")
if err != nil { if err != nil {
log.Fatalf("Could not load cfg: %s", err) log.Fatalf("Could not load cfg: %s", err)
} }

View file

@ -25,12 +25,12 @@ func GetAliasesHandler(store db.DB) http.HandlerFunc {
trackIDStr := r.URL.Query().Get("track_id") trackIDStr := r.URL.Query().Get("track_id")
if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" { if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" {
l.Debug().Msgf("Request is missing required parameters") l.Debug().Msgf("GetAliasesHandler: Request is missing required parameters")
utils.WriteError(w, "artist_id, album_id, or track_id must be provided", http.StatusBadRequest) utils.WriteError(w, "artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
return return
} }
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) { if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
l.Debug().Msgf("Request is has more than one of artist_id, album_id, and track_id") l.Debug().Msgf("GetAliasesHandler: Request is has more than one of artist_id, album_id, and track_id")
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest) utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
return return
} }
@ -97,12 +97,12 @@ func DeleteAliasHandler(store db.DB) http.HandlerFunc {
alias := r.URL.Query().Get("alias") alias := r.URL.Query().Get("alias")
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") { if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
l.Debug().Msgf("Request is missing required parameters") l.Debug().Msgf("DeleteAliasHandler: Request is missing required parameters")
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest) utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
return return
} }
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) { if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
l.Debug().Msgf("Request is has more than one of artist_id, album_id, and track_id") l.Debug().Msgf("DeleteAliasHandler: Request is has more than one of artist_id, album_id, and track_id")
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest) utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
return return
} }
@ -177,12 +177,12 @@ func CreateAliasHandler(store db.DB) http.HandlerFunc {
trackIDStr := r.URL.Query().Get("track_id") trackIDStr := r.URL.Query().Get("track_id")
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") { if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
l.Debug().Msgf("Request is missing required parameters") l.Debug().Msgf("CreateAliasHandler: Request is missing required parameters")
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest) utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
return return
} }
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) { if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
l.Debug().Msgf("Request is has more than one of artist_id, album_id, and track_id") l.Debug().Msgf("CreateAliasHandler: Request is has more than one of artist_id, album_id, and track_id")
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest) utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
return return
} }
@ -247,12 +247,12 @@ func SetPrimaryAliasHandler(store db.DB) http.HandlerFunc {
alias := r.URL.Query().Get("alias") alias := r.URL.Query().Get("alias")
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") { if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
l.Debug().Msgf("Request is missing required parameters") l.Debug().Msgf("SetPrimaryAliasHandler: Request is missing required parameters")
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest) utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
return return
} }
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) { if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
l.Debug().Msgf("Request is has more than one of artist_id, album_id, and track_id") l.Debug().Msgf("SetPrimaryAliasHandler: Request is has more than one of artist_id, album_id, and track_id")
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest) utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
return return
} }

View file

@ -3,6 +3,7 @@ package middleware
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"fmt"
"math/big" "math/big"
"net/http" "net/http"
"runtime/debug" "runtime/debug"
@ -63,9 +64,21 @@ func Logger(baseLogger *zerolog.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) { fn := func(w http.ResponseWriter, r *http.Request) {
reqID := GetRequestID(r.Context()) reqID := GetRequestID(r.Context())
l := baseLogger.With().Str("request_id", reqID).Logger()
// Inject logger with request_id into the context loggerCtx := baseLogger.With().Str("request_id", reqID)
for key, values := range r.URL.Query() {
if strings.Contains(strings.ToLower(key), "password") {
continue
}
if len(values) > 0 {
loggerCtx = loggerCtx.Str(fmt.Sprintf("query.%s", key), values[0])
}
}
l := loggerCtx.Logger()
// Inject logger into context
r = logger.Inject(r, &l) r = logger.Inject(r, &l)
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
@ -82,20 +95,18 @@ func Logger(baseLogger *zerolog.Logger) func(next http.Handler) http.Handler {
utils.WriteError(ww, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) utils.WriteError(ww, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
pathS := strings.Split(r.URL.Path, "/")
if len(pathS) > 1 && pathS[1] == "apis" {
l.Info().
Str("type", "access").
Timestamp().
Msgf("Received %s %s - Responded with %d in %.2fms", r.Method, r.URL.Path, ww.Status(), float64(t2.Sub(t1).Nanoseconds())/1_000_000.0)
} else {
l.Debug().
Str("type", "access").
Timestamp().
Msgf("Received %s %s - Responded with %d in %.2fms", r.Method, r.URL.Path, ww.Status(), float64(t2.Sub(t1).Nanoseconds())/1_000_000.0)
}
pathS := strings.Split(r.URL.Path, "/")
msg := fmt.Sprintf("Received %s %s - Responded with %d in %.2fms",
r.Method, r.URL.Path, ww.Status(), float64(t2.Sub(t1).Nanoseconds())/1_000_000.0)
if len(pathS) > 1 && pathS[1] == "apis" {
l.Info().Str("type", "access").Timestamp().Msg(msg)
} else {
l.Debug().Str("type", "access").Timestamp().Msg(msg)
}
}() }()
next.ServeHTTP(ww, r) next.ServeHTTP(ww, r)
} }
return http.HandlerFunc(fn) return http.HandlerFunc(fn)

View file

@ -2,6 +2,7 @@ package middleware
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -24,25 +25,34 @@ func ValidateSession(store db.DB) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context()) l := logger.FromContext(r.Context())
l.Debug().Msgf("ValidateSession: Checking user authentication via session cookie")
cookie, err := r.Cookie("koito_session") cookie, err := r.Cookie("koito_session")
var sid uuid.UUID var sid uuid.UUID
if err == nil { if err == nil {
sid, err = uuid.Parse(cookie.Value) sid, err = uuid.Parse(cookie.Value)
if err != nil { if err != nil {
l.Err(err).Msg("ValidateSession: Could not parse UUID from session cookie")
utils.WriteError(w, "session cookie is invalid", http.StatusUnauthorized) utils.WriteError(w, "session cookie is invalid", http.StatusUnauthorized)
return return
} }
} else {
l.Debug().Msgf("ValidateSession: No session cookie found; attempting API key authentication")
utils.WriteError(w, "session cookie is missing", http.StatusUnauthorized)
return
} }
l.Debug().Msg("Retrieved login cookie from request") l.Debug().Msg("ValidateSession: Retrieved login cookie from request")
u, err := store.GetUserBySession(r.Context(), sid) u, err := store.GetUserBySession(r.Context(), sid)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to get user from session") l.Err(fmt.Errorf("ValidateSession: %w", err)).Msg("Error accessing database")
utils.WriteError(w, "internal server error", http.StatusInternalServerError) utils.WriteError(w, "internal server error", http.StatusInternalServerError)
return return
} }
if u == nil { if u == nil {
l.Debug().Msg("ValidateSession: No user with session id found")
utils.WriteError(w, "unauthorized", http.StatusUnauthorized) utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return return
} }
@ -50,11 +60,11 @@ func ValidateSession(store db.DB) func(next http.Handler) http.Handler {
ctx := context.WithValue(r.Context(), UserContextKey, u) ctx := context.WithValue(r.Context(), UserContextKey, u)
r = r.WithContext(ctx) r = r.WithContext(ctx)
l.Debug().Msgf("Refreshing session for user '%s'", u.Username) l.Debug().Msgf("ValidateSession: Refreshing session for user '%s'", u.Username)
store.RefreshSession(r.Context(), sid, time.Now().Add(30*24*time.Hour)) store.RefreshSession(r.Context(), sid, time.Now().Add(30*24*time.Hour))
l.Debug().Msgf("Refreshed session for user '%s'", u.Username) l.Debug().Msgf("ValidateSession: Refreshed session for user '%s'", u.Username)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
@ -67,10 +77,19 @@ func ValidateApiKey(store db.DB) func(next http.Handler) http.Handler {
ctx := r.Context() ctx := r.Context()
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Debug().Msg("ValidateApiKey: Checking if user is already authenticated")
u := GetUserFromContext(ctx)
if u != nil {
l.Debug().Msg("ValidateApiKey: User is already authenticated; skipping API key authentication")
next.ServeHTTP(w, r)
return
}
authh := r.Header.Get("Authorization") authh := r.Header.Get("Authorization")
s := strings.Split(authh, "Token ") s := strings.Split(authh, "Token ")
if len(s) < 2 { if len(s) < 2 {
l.Debug().Msg("Authorization header must be formatted 'Token {token}'") l.Debug().Msg("ValidateApiKey: Authorization header must be formatted 'Token {token}'")
utils.WriteError(w, "unauthorized", http.StatusUnauthorized) utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return return
} }

View file

@ -25,12 +25,16 @@ func bindRoutes(
db db.DB, db db.DB,
mbz mbz.MusicBrainzCaller, mbz mbz.MusicBrainzCaller,
) { ) {
if !(len(cfg.AllowedOrigins()) == 0) && !(cfg.AllowedOrigins()[0] == "") {
r.Use(cors.Handler(cors.Options{
AllowedOrigins: cfg.AllowedOrigins(),
AllowedMethods: []string{"GET", "OPTIONS", "HEAD"},
}))
}
r.With(chimiddleware.RequestSize(5<<20)). r.With(chimiddleware.RequestSize(5<<20)).
With(middleware.AllowedHosts).
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.Use(middleware.AllowedHosts)
r.Get("/artist", handlers.GetArtistHandler(db)) r.Get("/artist", handlers.GetArtistHandler(db))
r.Get("/album", handlers.GetAlbumHandler(db)) r.Get("/album", handlers.GetAlbumHandler(db))
r.Get("/track", handlers.GetTrackHandler(db)) r.Get("/track", handlers.GetTrackHandler(db))

View file

@ -268,7 +268,7 @@ func TestMain(m *testing.M) {
log.Fatalf("Could not start resource: %s", err) log.Fatalf("Could not start resource: %s", err)
} }
err = cfg.Load(getTestGetenv(resource)) err = cfg.Load(getTestGetenv(resource), "test")
if err != nil { if err != nil {
log.Fatalf("Could not load cfg: %s", err) log.Fatalf("Could not load cfg: %s", err)
} }

View file

@ -36,6 +36,7 @@ const (
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT" SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS" ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
DISABLE_RATE_LIMIT_ENV = "KOITO_DISABLE_RATE_LIMIT" DISABLE_RATE_LIMIT_ENV = "KOITO_DISABLE_RATE_LIMIT"
THROTTLE_IMPORTS_MS = "KOITO_THROTTLE_IMPORTS_MS" THROTTLE_IMPORTS_MS = "KOITO_THROTTLE_IMPORTS_MS"
IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX" IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX"
@ -64,6 +65,7 @@ type config struct {
skipImport bool skipImport bool
allowedHosts []string allowedHosts []string
allowAllHosts bool allowAllHosts bool
allowedOrigins []string
disableRateLimit bool disableRateLimit bool
importThrottleMs int importThrottleMs int
userAgent string userAgent string
@ -78,21 +80,18 @@ var (
) )
// Initialize initializes the global configuration using the provided getenv function. // Initialize initializes the global configuration using the provided getenv function.
func Load(getenv func(string) string) error { func Load(getenv func(string) string, version string) error {
var err error var err error
once.Do(func() { once.Do(func() {
globalConfig, err = loadConfig(getenv) globalConfig, err = loadConfig(getenv, version)
}) })
return err return err
} }
// loadConfig loads the configuration from environment variables. // loadConfig loads the configuration from environment variables.
func loadConfig(getenv func(string) string) (*config, error) { func loadConfig(getenv func(string) string, version string) (*config, error) {
cfg := new(config) cfg := new(config)
// cfg.baseUrl = getenv(BASE_URL_ENV)
// if cfg.baseUrl == "" {
// cfg.baseUrl = defaultBaseUrl
// }
cfg.databaseUrl = getenv(DATABASE_URL_ENV) cfg.databaseUrl = getenv(DATABASE_URL_ENV)
if cfg.databaseUrl == "" { if cfg.databaseUrl == "" {
return nil, errors.New("required parameter " + DATABASE_URL_ENV + " not provided") return nil, errors.New("required parameter " + DATABASE_URL_ENV + " not provided")
@ -139,7 +138,7 @@ func loadConfig(getenv func(string) string) (*config, error) {
cfg.disableMusicBrainz = parseBool(getenv(DISABLE_MUSICBRAINZ_ENV)) cfg.disableMusicBrainz = parseBool(getenv(DISABLE_MUSICBRAINZ_ENV))
cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV)) cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV))
cfg.userAgent = "Koito v0.0.1 (contact@koito.io)" cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version)
if getenv(DEFAULT_USERNAME_ENV) == "" { if getenv(DEFAULT_USERNAME_ENV) == "" {
cfg.defaultUsername = "admin" cfg.defaultUsername = "admin"
@ -161,6 +160,9 @@ func loadConfig(getenv func(string) string) (*config, error) {
cfg.allowedHosts = strings.Split(rawHosts, ",") cfg.allowedHosts = strings.Split(rawHosts, ",")
cfg.allowAllHosts = cfg.allowedHosts[0] == "*" cfg.allowAllHosts = cfg.allowedHosts[0] == "*"
rawCors := getenv(CORS_ORIGINS_ENV)
cfg.allowedOrigins = strings.Split(rawCors, ",")
switch strings.ToLower(getenv(LOG_LEVEL_ENV)) { switch strings.ToLower(getenv(LOG_LEVEL_ENV)) {
case "debug": case "debug":
cfg.logLevel = 0 cfg.logLevel = 0
@ -312,6 +314,12 @@ func AllowAllHosts() bool {
return globalConfig.allowAllHosts return globalConfig.allowAllHosts
} }
func AllowedOrigins() []string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.allowedOrigins
}
func RateLimitDisabled() bool { func RateLimitDisabled() bool {
lock.RLock() lock.RLock()
defer lock.RUnlock() defer lock.RUnlock()

View file

@ -281,6 +281,13 @@ func TestDeleteAlbumAlias(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.True(t, exists, "expected alias to still exist") assert.True(t, exists, "expected alias to still exist")
// Ensure primary alias cannot be deleted
err = store.DeleteAlbumAlias(ctx, rg.ID, "Test Album")
require.NoError(t, err) // shouldn't error when nothing is deleted
rg, err = store.GetAlbum(ctx, db.GetAlbumOpts{ID: rg.ID})
require.NoError(t, err)
assert.Equal(t, "Test Album", rg.Title)
truncateTestData(t) truncateTestData(t)
} }
func TestGetAllAlbumAliases(t *testing.T) { func TestGetAllAlbumAliases(t *testing.T) {

View file

@ -195,6 +195,13 @@ func TestDeleteArtistAlias(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.True(t, exists, "expected alias to still exist") assert.True(t, exists, "expected alias to still exist")
// Ensure primary alias cannot be deleted
err = store.DeleteArtistAlias(ctx, artist.ID, "Alias Artist")
require.NoError(t, err) // shouldn't error when nothing is deleted
artist, err = store.GetArtist(ctx, db.GetArtistOpts{ID: 1})
require.NoError(t, err)
assert.Equal(t, "Alias Artist", artist.Name)
truncateTestData(t) truncateTestData(t)
} }
func TestDeleteArtist(t *testing.T) { func TestDeleteArtist(t *testing.T) {

View file

@ -45,7 +45,7 @@ func TestMain(m *testing.M) {
log.Fatalf("Could not start resource: %s", err) log.Fatalf("Could not start resource: %s", err)
} }
err = cfg.Load(getTestGetenv(resource)) err = cfg.Load(getTestGetenv(resource), "test")
if err != nil { if err != nil {
log.Fatalf("Could not load cfg: %s", err) log.Fatalf("Could not load cfg: %s", err)
} }

View file

@ -198,6 +198,13 @@ func TestTrackAliases(t *testing.T) {
err = store.SetPrimaryTrackAlias(ctx, 1, "Fake Alias") err = store.SetPrimaryTrackAlias(ctx, 1, "Fake Alias")
require.Error(t, err) require.Error(t, err)
// Ensure primary alias cannot be deleted
err = store.DeleteTrackAlias(ctx, track.ID, "Alias One")
require.NoError(t, err) // shouldn't error when nothing is deleted
track, err = store.GetTrack(ctx, db.GetTrackOpts{ID: 1})
require.NoError(t, err)
assert.Equal(t, "Alias One", track.Title)
store.SetPrimaryTrackAlias(ctx, 1, "Track One") store.SetPrimaryTrackAlias(ctx, 1, "Track One")
} }

View file

@ -105,6 +105,7 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall
RecordingMbzID: trackMbzID, RecordingMbzID: trackMbzID,
ReleaseTitle: album, ReleaseTitle: album,
ReleaseMbzID: albumMbzID, ReleaseMbzID: albumMbzID,
Client: "lastfm",
Time: ts, Time: ts,
UserID: 1, UserID: 1,
} }

View file

@ -77,6 +77,7 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error {
TrackTitle: item.Track.Title, TrackTitle: item.Track.Title,
ReleaseTitle: item.Track.Album.Title, ReleaseTitle: item.Track.Album.Title,
Time: ts.Local(), Time: ts.Local(),
Client: "maloja",
UserID: 1, UserID: 1,
} }
err = catalog.SubmitListen(ctx, store, opts) err = catalog.SubmitListen(ctx, store, opts)

View file

@ -64,6 +64,7 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error
ReleaseTitle: item.AlbumName, ReleaseTitle: item.AlbumName,
Duration: dur / 1000, Duration: dur / 1000,
Time: item.Timestamp, Time: item.Timestamp,
Client: "spotify",
UserID: 1, UserID: 1,
} }
err = catalog.SubmitListen(ctx, store, opts) err = catalog.SubmitListen(ctx, store, opts)