feat: version v0.0.2

pull/20/head v0.0.2
Gabe Farrell 6 months ago
parent 0dceaf017a
commit 7ff317756f

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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, "/") 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" { if len(pathS) > 1 && pathS[1] == "apis" {
l.Info(). l.Info().Str("type", "access").Timestamp().Msg(msg)
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 { } else {
l.Debug(). l.Debug().Str("type", "access").Timestamp().Msg(msg)
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)
} }
}() }()
next.ServeHTTP(ww, r) next.ServeHTTP(ww, r)
} }
return http.HandlerFunc(fn) return http.HandlerFunc(fn)

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

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

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save