feat: v0.0.8

pull/20/head v0.0.8
Gabe Farrell 6 months ago
parent 00e7782be2
commit 80b6f4deaa

@ -1,5 +1,18 @@
# v0.0.7 # v0.0.8
## Features
- An album artist can now be set as primary so that they are shown as the album artist in the top albums list
## Enhancements
- Show a few more items under "Last Played" on the home page
- Importing is now 4-5x faster
## Fixes ## Fixes
- Login form now correctly handles special characters - Merge selections now function correctly when selecting an item while another is selected
- Update User form now correctly handles special characters - Use anchor tags for top tracks and top albums
- Delete Listen button is now hidden when not logged in - UI fixes
## Updates
- Improved logging and error traces in logs
## Docs
- Add KOITO_FETCH_IMAGES_DURING_IMPORT to config reference

@ -226,6 +226,7 @@ type Artist = {
listen_count: number listen_count: number
musicbrainz_id: string musicbrainz_id: string
time_listened: number time_listened: number
is_primary: boolean
} }
type Album = { type Album = {
id: number, id: number,

@ -16,7 +16,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
const selectItem = (title: string, id: number) => { const selectItem = (title: string, id: number) => {
if (selected === id) { if (selected === id) {
setSelected(0) setSelected(0)
onSelect({id: id, title: title}) onSelect({id: 0, title: ''})
} else { } else {
setSelected(id) setSelected(id)
onSelect({id: id, title: title}) onSelect({id: id, title: title})

@ -35,101 +35,61 @@ export default function TopItemList<T extends Item>({ data, separators, type, cl
function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) { function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) {
const itemClasses = `flex items-center gap-2 hover:text-(--color-fg-secondary)` const itemClasses = `flex items-center gap-2`
const navigate = useNavigate();
const handleItemClick = (type: string, id: number) => {
navigate(`/${type.toLowerCase()}/${id}`);
};
const handleArtistClick = (event: React.MouseEvent) => {
// Stop the click from navigating to the album page
event.stopPropagation();
};
// Also stop keyboard events on the inner links from bubbling up
const handleArtistKeyDown = (event: React.KeyboardEvent) => {
event.stopPropagation();
}
switch (type) { switch (type) {
case "album": { case "album": {
const album = item as Album; const album = item as Album;
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
handleItemClick("album", album.id);
}
};
return ( return (
<div style={{fontSize: 12}}> <div style={{fontSize: 12}} className={itemClasses}>
<div <Link to={`/album/${album.id}`}>
className={itemClasses} <img loading="lazy" src={imageUrl(album.image, "small")} alt={album.title} className="min-w-[48px]" />
onClick={() => handleItemClick("album", album.id)} </Link>
onKeyDown={handleKeyDown}
role="link"
tabIndex={0}
aria-label={`View album: ${album.title}`}
style={{ cursor: 'pointer' }}
>
<img src={imageUrl(album.image, "small")} alt={album.title} />
<div> <div>
<Link to={`/album/${album.id}`} className="hover:text-(--color-fg-secondary)">
<span style={{fontSize: 14}}>{album.title}</span> <span style={{fontSize: 14}}>{album.title}</span>
</Link>
<br /> <br />
{album.is_various_artists ? {album.is_various_artists ?
<span className="color-fg-secondary">Various Artists</span> <span className="color-fg-secondary">Various Artists</span>
: :
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}> <div>
<ArtistLinks artists={album.artists ? [album.artists[0]] : [{id: 0, name: 'Unknown Artist'}]}/> <ArtistLinks artists={album.artists ? [album.artists[0]] : [{id: 0, name: 'Unknown Artist'}]}/>
</div> </div>
} }
<div className="color-fg-secondary">{album.listen_count} plays</div> <div className="color-fg-secondary">{album.listen_count} plays</div>
</div> </div>
</div> </div>
</div>
); );
} }
case "track": { case "track": {
const track = item as Track; const track = item as Track;
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
handleItemClick("track", track.id);
}
};
return ( return (
<div style={{fontSize: 12}}> <div style={{fontSize: 12}} className={itemClasses}>
<div <Link to={`/track/${track.id}`}>
className={itemClasses} <img loading="lazy" src={imageUrl(track.image, "small")} alt={track.title} className="min-w-[48px]" />
onClick={() => handleItemClick("track", track.id)} </Link>
onKeyDown={handleKeyDown}
role="link"
tabIndex={0}
aria-label={`View track: ${track.title}`}
style={{ cursor: 'pointer' }}
>
<img src={imageUrl(track.image, "small")} alt={track.title} />
<div> <div>
<Link to={`/track/${track.id}`} className="hover:text-(--color-fg-secondary)">
<span style={{fontSize: 14}}>{track.title}</span> <span style={{fontSize: 14}}>{track.title}</span>
</Link>
<br /> <br />
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}> <div>
<ArtistLinks artists={track.artists || [{id: 0, Name: 'Unknown Artist'}]}/> <ArtistLinks artists={track.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
</div> </div>
<div className="color-fg-secondary">{track.listen_count} plays</div> <div className="color-fg-secondary">{track.listen_count} plays</div>
</div> </div>
</div> </div>
</div>
); );
} }
case "artist": { case "artist": {
const artist = item as Artist; const artist = item as Artist;
return ( return (
<div style={{fontSize: 12}}> <div style={{fontSize: 12}}>
<Link className={itemClasses+' mt-1 mb-[6px]'} to={`/artist/${artist.id}`}> <Link className={itemClasses+' mt-1 mb-[6px] hover:text-(--color-fg-secondary)'} to={`/artist/${artist.id}`}>
<img src={imageUrl(artist.image, "small")} alt={artist.name} /> <img loading="lazy" src={imageUrl(artist.image, "small")} alt={artist.name} className="min-w-[48px]" />
<div> <div>
<span style={{fontSize: 14}}>{artist.name}</span> <span style={{fontSize: 14}}>{artist.name}</span>
<div className="color-fg-secondary">{artist.listen_count} plays</div> <div className="color-fg-secondary">{artist.listen_count} plays</div>

@ -1,10 +1,11 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { createAlias, deleteAlias, getAliases, getAlbum, setPrimaryAlias, type Album, type Alias } from "api/api"; import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api";
import { Modal } from "./Modal"; import { Modal } from "../Modal";
import { AsyncButton } from "../AsyncButton"; import { AsyncButton } from "../../AsyncButton";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Trash } from "lucide-react"; import { Trash } from "lucide-react";
import SetVariousArtists from "./SetVariousArtist"; import SetVariousArtists from "./SetVariousArtist";
import SetPrimaryArtist from "./SetPrimaryArtist";
interface Props { interface Props {
type: string type: string
@ -18,7 +19,6 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
const [loading, setLoading ] = useState(false) const [loading, setLoading ] = useState(false)
const [err, setError ] = useState<string>() const [err, setError ] = useState<string>()
const [displayData, setDisplayData] = useState<Alias[]>([]) const [displayData, setDisplayData] = useState<Alias[]>([])
const [variousArtists, setVariousArtists] = useState(false)
const { isPending, isError, data, error } = useQuery({ const { isPending, isError, data, error } = useQuery({
queryKey: [ queryKey: [
@ -125,7 +125,10 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
</div> </div>
</div> </div>
{ type.toLowerCase() === "album" && { type.toLowerCase() === "album" &&
<>
<SetVariousArtists id={id} /> <SetVariousArtists id={id} />
<SetPrimaryArtist id={id} type="album" />
</>
} }
</div> </div>
</Modal> </Modal>

@ -0,0 +1,99 @@
import { useQuery } from "@tanstack/react-query";
import { getAlbum, type Artist } from "api/api";
import { useEffect, useState } from "react"
interface Props {
id: number
type: string
}
export default function SetPrimaryArtist({ id, type }: Props) {
const [err, setErr] = useState('')
const [primary, setPrimary] = useState<Artist>()
const [success, setSuccess] = useState('')
const { isPending, isError, data, error } = useQuery({
queryKey: [
'get-artists-'+type.toLowerCase(),
{
id: id
},
],
queryFn: () => {
return fetch('/apis/web/v1/artists?'+type.toLowerCase()+'_id='+id).then(r => r.json()) as Promise<Artist[]>;
},
});
useEffect(() => {
if (data) {
for (let a of data) {
if (a.is_primary) {
setPrimary(a)
break
}
}
}
}, [data])
if (isError) {
return (
<p className="error">Error: {error.message}</p>
)
}
if (isPending) {
return (
<p>Loading...</p>
)
}
const updatePrimary = (artist: number, val: boolean) => {
setErr('');
setSuccess('');
fetch(`/apis/web/v1/artists/primary?artist_id=${artist}&${type.toLowerCase()}_id=${id}&is_primary=${val}`, {
method: 'POST',
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
})
.then(r => {
if (r.ok) {
setSuccess('successfully updated primary artists');
} else {
r.json().then(r => setErr(r.error));
}
});
}
return (
<div className="w-full">
<h2>Set Primary Artist</h2>
<div className="flex flex-col gap-4">
<select
name="mark-various-artists"
id="mark-various-artists"
className="w-60 px-3 py-2 rounded-md"
value={primary?.name || ""}
onChange={(e) => {
for (let a of data) {
if (a.name === e.target.value) {
setPrimary(a);
updatePrimary(a.id, true);
}
}
}}
>
<option value="" disabled>
Select an artist
</option>
{data.map((a) => (
<option key={a.id} value={a.name}>
{a.name}
</option>
))}
</select>
{err && <p className="error">{err}</p>}
{success && <p className="success">{success}</p>}
</div>
</div>
);
}

@ -73,6 +73,7 @@ export default function SetVariousArtists({ id }: Props) {
<option value="false">False</option> <option value="false">False</option>
</select> </select>
{err && <p className="error">{err}</p>} {err && <p className="error">{err}</p>}
{success && <p className="success">{success}</p>}
</div> </div>
</div> </div>
) )

@ -34,15 +34,11 @@ export default function MergeModal(props: Props) {
} }
const toggleSelect = ({title, id}: {title: string, id: number}) => { const toggleSelect = ({title, id}: {title: string, id: number}) => {
if (mergeTarget.id === 0) {
setMergeTarget({title: title, id: id}) setMergeTarget({title: title, id: id})
} else {
setMergeTarget({title:"", id: 0})
}
} }
useEffect(() => { useEffect(() => {
console.log(mergeTarget) console.log("mergeTarget",mergeTarget)
}, [mergeTarget]) }, [mergeTarget])
const doMerge = () => { const doMerge = () => {

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

@ -7,8 +7,8 @@ import { useAppContext } from "~/providers/AppProvider";
import MergeModal from "~/components/modals/MergeModal"; import MergeModal from "~/components/modals/MergeModal";
import ImageReplaceModal from "~/components/modals/ImageReplaceModal"; import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
import DeleteModal from "~/components/modals/DeleteModal"; import DeleteModal from "~/components/modals/DeleteModal";
import RenameModal from "~/components/modals/EditModal"; import RenameModal from "~/components/modals/EditModal/EditModal";
import EditModal from "~/components/modals/EditModal"; import EditModal from "~/components/modals/EditModal/EditModal";
export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise<Response> export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise<Response>
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
@ -69,9 +69,9 @@ 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 flex-wrap relative"> <div className="flex gap-8 flex-wrap md:flex-nowrap relative">
<div className="flex flex-col justify-around"> <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" /> <img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="md:min-w-[385px] w-[220px] h-auto shadow-(--color-shadow) shadow-lg" />
</div> </div>
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
<h3>{props.type}</h3> <h3>{props.type}</h3>

@ -0,0 +1,48 @@
-- +goose Up
-- +goose StatementBegin
SELECT 'up SQL query';
-- +goose StatementEnd
ALTER TABLE artist_tracks
ADD COLUMN is_primary boolean NOT NULL DEFAULT false;
ALTER TABLE artist_releases
ADD COLUMN is_primary boolean NOT NULL DEFAULT false;
-- +goose StatementBegin
CREATE FUNCTION get_artists_for_release(release_id INTEGER)
RETURNS JSONB AS $$
SELECT json_agg(
jsonb_build_object('id', a.id, 'name', a.name)
ORDER BY ar.is_primary DESC, a.name
)
FROM artist_releases ar
JOIN artists_with_name a ON a.id = ar.artist_id
WHERE ar.release_id = $1;
$$ LANGUAGE sql STABLE;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION get_artists_for_track(track_id INTEGER)
RETURNS JSONB AS $$
SELECT json_agg(
jsonb_build_object('id', a.id, 'name', a.name)
ORDER BY at.is_primary DESC, a.name
)
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = $1;
$$ LANGUAGE sql STABLE;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 'down SQL query';
-- +goose StatementEnd
ALTER TABLE artist_tracks
DROP COLUMN is_primary;
ALTER TABLE artist_releases
DROP COLUMN is_primary;
DROP FUNCTION IF EXISTS get_artists_for_release(INTEGER);
DROP FUNCTION IF EXISTS get_artists_for_track(INTEGER);

@ -14,22 +14,24 @@ GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
-- name: GetTrackArtists :many -- name: GetTrackArtists :many
SELECT SELECT
a.* a.*,
at.is_primary as is_primary
FROM artists_with_name a FROM artists_with_name a
LEFT JOIN artist_tracks at ON a.id = at.artist_id LEFT JOIN artist_tracks at ON a.id = at.artist_id
WHERE at.track_id = $1 WHERE at.track_id = $1
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name; GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, at.is_primary;
-- name: GetArtistByImage :one -- name: GetArtistByImage :one
SELECT * FROM artists WHERE image = $1 LIMIT 1; SELECT * FROM artists WHERE image = $1 LIMIT 1;
-- name: GetReleaseArtists :many -- name: GetReleaseArtists :many
SELECT SELECT
a.* a.*,
ar.is_primary as is_primary
FROM artists_with_name a FROM artists_with_name a
LEFT JOIN artist_releases ar ON a.id = ar.artist_id LEFT JOIN artist_releases ar ON a.id = ar.artist_id
WHERE ar.release_id = $1 WHERE ar.release_id = $1
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name; GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, ar.is_primary;
-- name: GetArtistByName :one -- name: GetArtistByName :one
WITH artist_with_aliases AS ( WITH artist_with_aliases AS (

@ -8,12 +8,7 @@ SELECT
l.*, l.*,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2 WHERE l.listened_at BETWEEN $1 AND $2
@ -25,12 +20,7 @@ SELECT
l.*, l.*,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
JOIN artist_tracks at ON t.id = at.track_id JOIN artist_tracks at ON t.id = at.track_id
@ -44,12 +34,7 @@ SELECT
l.*, l.*,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2 WHERE l.listened_at BETWEEN $1 AND $2
@ -62,12 +47,7 @@ SELECT
l.*, l.*,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2 WHERE l.listened_at BETWEEN $1 AND $2

@ -33,12 +33,7 @@ LIMIT 1;
SELECT SELECT
r.*, r.*,
COUNT(*) AS listen_count, COUNT(*) AS listen_count,
( get_artists_for_release(r.id) AS artists
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
FROM artists_with_name a
JOIN artist_releases ar ON ar.artist_id = a.id
WHERE ar.release_id = r.id
) AS artists
FROM listens l FROM listens l
JOIN tracks t ON l.track_id = t.id JOIN tracks t ON l.track_id = t.id
JOIN releases_with_title r ON t.release_id = r.id JOIN releases_with_title r ON t.release_id = r.id
@ -53,12 +48,7 @@ LIMIT $3 OFFSET $4;
SELECT SELECT
r.*, r.*,
COUNT(*) AS listen_count, COUNT(*) AS listen_count,
( get_artists_for_release(r.id) AS artists
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
FROM artists_with_name a
JOIN artist_releases ar ON ar.artist_id = a.id
WHERE ar.release_id = r.id
) AS artists
FROM listens l FROM listens l
JOIN tracks t ON l.track_id = t.id JOIN tracks t ON l.track_id = t.id
JOIN releases_with_title r ON t.release_id = r.id JOIN releases_with_title r ON t.release_id = r.id
@ -88,12 +78,7 @@ ON CONFLICT DO NOTHING;
-- name: GetReleasesWithoutImages :many -- name: GetReleasesWithoutImages :many
SELECT SELECT
r.*, r.*,
( get_artists_for_release(r.id) AS artists
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
FROM artists_with_name a
JOIN artist_releases ar ON a.id = ar.artist_id
WHERE ar.release_id = r.id
) AS artists
FROM releases_with_title r FROM releases_with_title r
WHERE r.image IS NULL WHERE r.image IS NULL
AND r.id > $2 AND r.id > $2
@ -108,6 +93,10 @@ WHERE id = $1;
UPDATE releases SET various_artists = $2 UPDATE releases SET various_artists = $2
WHERE id = $1; WHERE id = $1;
-- name: UpdateReleasePrimaryArtist :exec
UPDATE artist_releases SET is_primary = $3
WHERE artist_id = $1 AND release_id = $2;
-- name: UpdateReleaseImage :exec -- name: UpdateReleaseImage :exec
UPDATE releases SET image = $2, image_source = $3 UPDATE releases SET image = $2, image_source = $3
WHERE id = $1; WHERE id = $1;

@ -42,12 +42,7 @@ SELECT
ranked.release_id, ranked.release_id,
ranked.image, ranked.image,
ranked.score, ranked.score,
( get_artists_for_track(ranked.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = ranked.id
) AS artists
FROM ( FROM (
SELECT SELECT
t.id, t.id,
@ -74,12 +69,7 @@ SELECT
ranked.release_id, ranked.release_id,
ranked.image, ranked.image,
ranked.score, ranked.score,
( get_artists_for_track(ranked.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = ranked.id
) AS artists
FROM ( FROM (
SELECT SELECT
t.id, t.id,
@ -106,12 +96,7 @@ SELECT
ranked.image, ranked.image,
ranked.various_artists, ranked.various_artists,
ranked.score, ranked.score,
( get_artists_for_release(ranked.id) AS artists
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
FROM artists_with_name a
JOIN artist_releases ar ON ar.artist_id = a.id
WHERE ar.release_id = ranked.id
) AS artists
FROM ( FROM (
SELECT SELECT
r.id, r.id,
@ -137,12 +122,7 @@ SELECT
ranked.image, ranked.image,
ranked.various_artists, ranked.various_artists,
ranked.score, ranked.score,
( get_artists_for_release(ranked.id) AS artists
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
FROM artists_with_name a
JOIN artist_releases ar ON ar.artist_id = a.id
WHERE ar.release_id = ranked.id
) AS artists
FROM ( FROM (
SELECT SELECT
r.id, r.id,

@ -43,12 +43,7 @@ SELECT
t.release_id, t.release_id,
r.image, r.image,
COUNT(*) AS listen_count, COUNT(*) AS listen_count,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
JOIN releases r ON t.release_id = r.id JOIN releases r ON t.release_id = r.id
@ -65,12 +60,7 @@ SELECT
t.release_id, t.release_id,
r.image, r.image,
COUNT(*) AS listen_count, COUNT(*) AS listen_count,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at2
JOIN artists_with_name a ON a.id = at2.artist_id
WHERE at2.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
JOIN releases r ON t.release_id = r.id JOIN releases r ON t.release_id = r.id
@ -89,12 +79,7 @@ SELECT
t.release_id, t.release_id,
r.image, r.image,
COUNT(*) AS listen_count, COUNT(*) AS listen_count,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at2
JOIN artists_with_name a ON a.id = at2.artist_id
WHERE at2.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
JOIN releases r ON t.release_id = r.id JOIN releases r ON t.release_id = r.id
@ -135,5 +120,9 @@ WHERE id = $1;
UPDATE tracks SET release_id = $2 UPDATE tracks SET release_id = $2
WHERE release_id = $1; WHERE release_id = $1;
-- name: UpdateTrackPrimaryArtist :exec
UPDATE artist_tracks SET is_primary = $3
WHERE artist_id = $1 AND track_id = $2;
-- name: DeleteTrack :exec -- name: DeleteTrack :exec
DELETE FROM tracks WHERE id = $1; DELETE FROM tracks WHERE id = $1;

@ -70,6 +70,9 @@ Koito is configured using **environment variables**. This is the full list of co
- 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_FETCH_IMAGES_DURING_IMPORT
- Default: `false`
- Description: When true, images will be downloaded and cached during imports.
##### KOITO_CORS_ALLOWED_ORIGINS ##### KOITO_CORS_ALLOWED_ORIGINS
- Default: No CORS policy - Default: No CORS policy
- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins. - Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins.

@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
@ -40,44 +39,43 @@ func GetAliasesHandler(store db.DB) http.HandlerFunc {
if artistIDStr != "" { if artistIDStr != "" {
artistID, err := strconv.Atoi(artistIDStr) artistID, err := strconv.Atoi(artistIDStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Invalid artist id") l.Debug().AnErr("error", err).Msg("GetAliasesHandler: Invalid artist id")
utils.WriteError(w, "invalid artist_id", http.StatusBadRequest) utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
return return
} }
aliases, err = store.GetAllArtistAliases(ctx, int32(artistID)) aliases, err = store.GetAllArtistAliases(ctx, int32(artistID))
if err != nil { if err != nil {
l.Err(fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Failed to get artist aliases") l.Err(err).Msg("GetAliasesHandler: Failed to get artist aliases")
utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError) utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError)
return return
} }
} else if albumIDStr != "" { } else if albumIDStr != "" {
albumID, err := strconv.Atoi(albumIDStr) albumID, err := strconv.Atoi(albumIDStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Invalid album id") l.Debug().AnErr("error", err).Msg("GetAliasesHandler: Invalid album id")
utils.WriteError(w, "invalid album_id", http.StatusBadRequest) utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
return return
} }
aliases, err = store.GetAllAlbumAliases(ctx, int32(albumID)) aliases, err = store.GetAllAlbumAliases(ctx, int32(albumID))
if err != nil { if err != nil {
l.Err(fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Failed to get album aliases") l.Err(err).Msg("GetAliasesHandler: Failed to get album aliases")
utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError) utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError)
return return
} }
} else if trackIDStr != "" { } else if trackIDStr != "" {
trackID, err := strconv.Atoi(trackIDStr) trackID, err := strconv.Atoi(trackIDStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Invalid track id") l.Debug().AnErr("error", err).Msg("GetAliasesHandler: Invalid track id")
utils.WriteError(w, "invalid track_id", http.StatusBadRequest) utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
return return
} }
aliases, err = store.GetAllTrackAliases(ctx, int32(trackID)) aliases, err = store.GetAllTrackAliases(ctx, int32(trackID))
if err != nil { if err != nil {
l.Err(fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Failed to get track aliases") l.Err(err).Msg("GetAliasesHandler: Failed to get track aliases")
utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError) utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError)
return return
} }
} }
utils.WriteJSON(w, http.StatusOK, aliases) utils.WriteJSON(w, http.StatusOK, aliases)
} }
} }
@ -88,7 +86,7 @@ func DeleteAliasHandler(store db.DB) http.HandlerFunc {
ctx := r.Context() ctx := r.Context()
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Debug().Msgf("DeleteAliasHandler: Got request with params: '%s'", r.URL.Query().Encode()) l.Debug().Msg("DeleteAliasHandler: Got request")
// Parse query parameters // Parse query parameters
artistIDStr := r.URL.Query().Get("artist_id") artistIDStr := r.URL.Query().Get("artist_id")
@ -97,52 +95,56 @@ 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("DeleteAliasHandler: Request is missing required parameters") l.Debug().Msg("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("DeleteAliasHandler: Request is has more than one of artist_id, album_id, and track_id") l.Debug().Msg("DeleteAliasHandler: Request 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
} }
var err error
if artistIDStr != "" { if artistIDStr != "" {
artistID, err := strconv.Atoi(artistIDStr) var artistID int
artistID, err = strconv.Atoi(artistIDStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Invalid artist id") l.Debug().AnErr("error", err).Msg("DeleteAliasHandler: Invalid artist id")
utils.WriteError(w, "invalid artist_id", http.StatusBadRequest) utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
return return
} }
err = store.DeleteArtistAlias(ctx, int32(artistID), alias) err = store.DeleteArtistAlias(ctx, int32(artistID), alias)
if err != nil { if err != nil {
l.Err(fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Failed to delete artist alias") l.Error().Err(err).Msg("DeleteAliasHandler: Failed to delete artist alias")
utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError) utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError)
return return
} }
} else if albumIDStr != "" { } else if albumIDStr != "" {
albumID, err := strconv.Atoi(albumIDStr) var albumID int
albumID, err = strconv.Atoi(albumIDStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Invalid album id") l.Debug().AnErr("error", err).Msg("DeleteAliasHandler: Invalid album id")
utils.WriteError(w, "invalid album_id", http.StatusBadRequest) utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
return return
} }
err = store.DeleteAlbumAlias(ctx, int32(albumID), alias) err = store.DeleteAlbumAlias(ctx, int32(albumID), alias)
if err != nil { if err != nil {
l.Err(fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Failed to delete album alias") l.Error().Err(err).Msg("DeleteAliasHandler: Failed to delete album alias")
utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError) utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError)
return return
} }
} else if trackIDStr != "" { } else if trackIDStr != "" {
trackID, err := strconv.Atoi(trackIDStr) var trackID int
trackID, err = strconv.Atoi(trackIDStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Invalid track id") l.Debug().AnErr("error", err).Msg("DeleteAliasHandler: Invalid track id")
utils.WriteError(w, "invalid track_id", http.StatusBadRequest) utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
return return
} }
err = store.DeleteTrackAlias(ctx, int32(trackID), alias) err = store.DeleteTrackAlias(ctx, int32(trackID), alias)
if err != nil { if err != nil {
l.Err(fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Failed to delete track alias") l.Error().Err(err).Msg("DeleteAliasHandler: Failed to delete track alias")
utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError) utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError)
return return
} }
@ -158,16 +160,18 @@ func CreateAliasHandler(store db.DB) http.HandlerFunc {
ctx := r.Context() ctx := r.Context()
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Debug().Msgf("CreateAliasHandler: Got request with params: '%s'", r.URL.Query().Encode()) l.Debug().Msg("CreateAliasHandler: Got request")
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {
l.Debug().AnErr("error", err).Msg("CreateAliasHandler: Failed to parse form")
utils.WriteError(w, "invalid request body", http.StatusBadRequest) utils.WriteError(w, "invalid request body", http.StatusBadRequest)
return return
} }
alias := r.FormValue("alias") alias := r.FormValue("alias")
if alias == "" { if alias == "" {
l.Debug().Msg("CreateAliasHandler: Alias parameter missing")
utils.WriteError(w, "alias must be provided", http.StatusBadRequest) utils.WriteError(w, "alias must be provided", http.StatusBadRequest)
return return
} }
@ -176,53 +180,54 @@ func CreateAliasHandler(store db.DB) http.HandlerFunc {
albumIDStr := r.URL.Query().Get("album_id") albumIDStr := r.URL.Query().Get("album_id")
trackIDStr := r.URL.Query().Get("track_id") trackIDStr := r.URL.Query().Get("track_id")
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") { if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" {
l.Debug().Msgf("CreateAliasHandler: Request is missing required parameters") l.Debug().Msg("CreateAliasHandler: Missing ID parameter")
utils.WriteError(w, "alias and 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("CreateAliasHandler: Request is has more than one of artist_id, album_id, and track_id") l.Debug().Msg("CreateAliasHandler: Multiple ID parameters provided")
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", http.StatusBadRequest)
return return
} }
var id int
if artistIDStr != "" { if artistIDStr != "" {
artistID, err := strconv.Atoi(artistIDStr) id, err = strconv.Atoi(artistIDStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Invalid artist id") l.Debug().AnErr("error", err).Msg("CreateAliasHandler: Invalid artist id")
utils.WriteError(w, "invalid artist_id", http.StatusBadRequest) utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
return return
} }
err = store.SaveArtistAliases(ctx, int32(artistID), []string{alias}, "Manual") err = store.SaveArtistAliases(ctx, int32(id), []string{alias}, "Manual")
if err != nil { if err != nil {
l.Err(fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Failed to save artist alias") l.Error().Err(err).Msg("CreateAliasHandler: Failed to save artist alias")
utils.WriteError(w, "failed to save alias", http.StatusInternalServerError) utils.WriteError(w, "failed to save alias", http.StatusInternalServerError)
return return
} }
} else if albumIDStr != "" { } else if albumIDStr != "" {
albumID, err := strconv.Atoi(albumIDStr) id, err = strconv.Atoi(albumIDStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Invalid album id") l.Debug().AnErr("error", err).Msg("CreateAliasHandler: Invalid album id")
utils.WriteError(w, "invalid album_id", http.StatusBadRequest) utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
return return
} }
err = store.SaveAlbumAliases(ctx, int32(albumID), []string{alias}, "Manual") err = store.SaveAlbumAliases(ctx, int32(id), []string{alias}, "Manual")
if err != nil { if err != nil {
l.Err(fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Failed to save album alias") l.Error().Err(err).Msg("CreateAliasHandler: Failed to save album alias")
utils.WriteError(w, "failed to save alias", http.StatusInternalServerError) utils.WriteError(w, "failed to save alias", http.StatusInternalServerError)
return return
} }
} else if trackIDStr != "" { } else if trackIDStr != "" {
trackID, err := strconv.Atoi(trackIDStr) id, err = strconv.Atoi(trackIDStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Invalid track id") l.Debug().AnErr("error", err).Msg("CreateAliasHandler: Invalid track id")
utils.WriteError(w, "invalid track_id", http.StatusBadRequest) utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
return return
} }
err = store.SaveTrackAliases(ctx, int32(trackID), []string{alias}, "Manual") err = store.SaveTrackAliases(ctx, int32(id), []string{alias}, "Manual")
if err != nil { if err != nil {
l.Err(fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Failed to save track alias") l.Error().Err(err).Msg("CreateAliasHandler: Failed to save track alias")
utils.WriteError(w, "failed to save alias", http.StatusInternalServerError) utils.WriteError(w, "failed to save alias", http.StatusInternalServerError)
return return
} }
@ -238,7 +243,7 @@ func SetPrimaryAliasHandler(store db.DB) http.HandlerFunc {
ctx := r.Context() ctx := r.Context()
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Debug().Msgf("SetPrimaryAliasHandler: Got request with params: '%s'", r.URL.Query().Encode()) l.Debug().Msg("SetPrimaryAliasHandler: Got request")
// Parse query parameters // Parse query parameters
artistIDStr := r.URL.Query().Get("artist_id") artistIDStr := r.URL.Query().Get("artist_id")
@ -246,53 +251,60 @@ func SetPrimaryAliasHandler(store db.DB) http.HandlerFunc {
trackIDStr := r.URL.Query().Get("track_id") trackIDStr := r.URL.Query().Get("track_id")
alias := r.URL.Query().Get("alias") alias := r.URL.Query().Get("alias")
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") { if alias == "" {
l.Debug().Msgf("SetPrimaryAliasHandler: Request is missing required parameters") l.Debug().Msg("SetPrimaryAliasHandler: Missing alias parameter")
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest) utils.WriteError(w, "alias must be provided", http.StatusBadRequest)
return
}
if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" {
l.Debug().Msg("SetPrimaryAliasHandler: Missing ID parameter")
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("SetPrimaryAliasHandler: Request is has more than one of artist_id, album_id, and track_id") l.Debug().Msg("SetPrimaryAliasHandler: Multiple ID parameters provided")
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", http.StatusBadRequest)
return return
} }
var id int
var err error
if artistIDStr != "" { if artistIDStr != "" {
artistID, err := strconv.Atoi(artistIDStr) id, err = strconv.Atoi(artistIDStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Invalid artist id") l.Debug().AnErr("error", err).Msg("SetPrimaryAliasHandler: Invalid artist id")
utils.WriteError(w, "invalid artist_id", http.StatusBadRequest) utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
return return
} }
err = store.SetPrimaryArtistAlias(ctx, int32(artistID), alias) err = store.SetPrimaryArtistAlias(ctx, int32(id), alias)
if err != nil { if err != nil {
l.Err(fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Failed to set artist primary alias") l.Error().Err(err).Msg("SetPrimaryAliasHandler: Failed to set artist primary alias")
utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError) utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError)
return return
} }
} else if albumIDStr != "" { } else if albumIDStr != "" {
albumID, err := strconv.Atoi(albumIDStr) id, err = strconv.Atoi(albumIDStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Invalid album id") l.Debug().AnErr("error", err).Msg("SetPrimaryAliasHandler: Invalid album id")
utils.WriteError(w, "invalid album_id", http.StatusBadRequest) utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
return return
} }
err = store.SetPrimaryAlbumAlias(ctx, int32(albumID), alias) err = store.SetPrimaryAlbumAlias(ctx, int32(id), alias)
if err != nil { if err != nil {
l.Err(fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Failed to set album primary alias") l.Error().Err(err).Msg("SetPrimaryAliasHandler: Failed to set album primary alias")
utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError) utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError)
return return
} }
} else if trackIDStr != "" { } else if trackIDStr != "" {
trackID, err := strconv.Atoi(trackIDStr) id, err = strconv.Atoi(trackIDStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Invalid track id") l.Debug().AnErr("error", err).Msg("SetPrimaryAliasHandler: Invalid track id")
utils.WriteError(w, "invalid track_id", http.StatusBadRequest) utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
return return
} }
err = store.SetPrimaryTrackAlias(ctx, int32(trackID), alias) err = store.SetPrimaryTrackAlias(ctx, int32(id), alias)
if err != nil { if err != nil {
l.Err(fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Failed to set track primary alias") l.Error().Err(err).Msg("SetPrimaryAliasHandler: Failed to set track primary alias")
utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError) utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError)
return return
} }

@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
@ -16,45 +15,47 @@ func GenerateApiKeyHandler(store db.DB) http.HandlerFunc {
ctx := r.Context() ctx := r.Context()
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Debug().Msgf("GenerateApiKeyHandler: Received request with params: '%s'", r.URL.Query().Encode()) l.Debug().Msg("GenerateApiKeyHandler: Received request")
user := middleware.GetUserFromContext(ctx) user := middleware.GetUserFromContext(ctx)
if user == nil { if user == nil {
l.Debug().Msg("GenerateApiKeyHandler: Invalid user retrieved from context") l.Debug().Msg("GenerateApiKeyHandler: Invalid user context")
utils.WriteError(w, "unauthorized", http.StatusUnauthorized) utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return return
} }
r.ParseForm() if err := r.ParseForm(); err != nil {
l.Debug().AnErr("error", err).Msg("GenerateApiKeyHandler: Failed to parse form")
utils.WriteError(w, "invalid request", http.StatusBadRequest)
return
}
label := r.FormValue("label") label := r.FormValue("label")
if label == "" { if label == "" {
l.Debug().Msg("GenerateApiKeyHandler: Request rejected due to missing label") l.Debug().Msg("GenerateApiKeyHandler: Missing label parameter")
utils.WriteError(w, "label is required", http.StatusBadRequest) utils.WriteError(w, "label is required", http.StatusBadRequest)
return return
} }
apiKey, err := utils.GenerateRandomString(48) apiKey, err := utils.GenerateRandomString(48)
if err != nil { if err != nil {
l.Err(fmt.Errorf("GenerateApiKeyHandler: %w", err)).Msg("Failed to generate API key") l.Error().Err(err).Msg("GenerateApiKeyHandler: Failed to generate API key")
utils.WriteError(w, "failed to generate api key", http.StatusInternalServerError) utils.WriteError(w, "failed to generate api key", http.StatusInternalServerError)
return return
} }
opts := db.SaveApiKeyOpts{ key, err := store.SaveApiKey(ctx, db.SaveApiKeyOpts{
UserID: user.ID, UserID: user.ID,
Key: apiKey, Key: apiKey,
Label: label, Label: label,
} })
l.Debug().Msgf("GenerateApiKeyHandler: Saving API key with options: %+v", opts)
key, err := store.SaveApiKey(ctx, opts)
if err != nil { if err != nil {
l.Err(fmt.Errorf("GenerateApiKeyHandler: %w", err)).Msg("Failed to save API key") l.Error().Err(err).Msg("GenerateApiKeyHandler: Failed to save API key")
utils.WriteError(w, "failed to save api key", http.StatusInternalServerError) utils.WriteError(w, "failed to save api key", http.StatusInternalServerError)
return return
} }
l.Debug().Msgf("GenerateApiKeyHandler: Successfully saved API key with ID: %d", key.ID) l.Debug().Msgf("GenerateApiKeyHandler: Successfully generated API key ID %d", key.ID)
utils.WriteJSON(w, http.StatusCreated, key) utils.WriteJSON(w, http.StatusCreated, key)
} }
} }
@ -64,39 +65,36 @@ func DeleteApiKeyHandler(store db.DB) http.HandlerFunc {
ctx := r.Context() ctx := r.Context()
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Debug().Msgf("DeleteApiKeyHandler: Received request with params: '%s'", r.URL.Query().Encode()) l.Debug().Msg("DeleteApiKeyHandler: Received request")
user := middleware.GetUserFromContext(ctx) user := middleware.GetUserFromContext(ctx)
if user == nil { if user == nil {
l.Debug().Msg("DeleteApiKeyHandler: User could not be verified (context user is nil)") l.Debug().Msg("DeleteApiKeyHandler: Invalid user context")
utils.WriteError(w, "unauthorized", http.StatusUnauthorized) utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return return
} }
idStr := r.URL.Query().Get("id") idStr := r.URL.Query().Get("id")
if idStr == "" { if idStr == "" {
l.Debug().Msg("DeleteApiKeyHandler: Request rejected due to missing ID") l.Debug().Msg("DeleteApiKeyHandler: Missing id parameter")
utils.WriteError(w, "id is required", http.StatusBadRequest) utils.WriteError(w, "id is required", http.StatusBadRequest)
return return
} }
apiKey, err := strconv.Atoi(idStr) apiKeyID, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("DeleteApiKeyHandler: %w", err)).Msg("Invalid API key ID") l.Debug().AnErr("error", err).Msg("DeleteApiKeyHandler: Invalid API key ID")
utils.WriteError(w, "id is invalid", http.StatusBadRequest) utils.WriteError(w, "invalid id", http.StatusBadRequest)
return return
} }
l.Debug().Msgf("DeleteApiKeyHandler: Deleting API key with ID: %d", apiKey) if err := store.DeleteApiKey(ctx, int32(apiKeyID)); err != nil {
l.Error().Err(err).Msg("DeleteApiKeyHandler: Failed to delete API key")
err = store.DeleteApiKey(ctx, int32(apiKey))
if err != nil {
l.Err(fmt.Errorf("DeleteApiKeyHandler: %w", err)).Msg("Failed to delete API key")
utils.WriteError(w, "failed to delete api key", http.StatusInternalServerError) utils.WriteError(w, "failed to delete api key", http.StatusInternalServerError)
return return
} }
l.Debug().Msgf("DeleteApiKeyHandler: Successfully deleted API key with ID: %d", apiKey) l.Debug().Msgf("DeleteApiKeyHandler: Successfully deleted API key ID %d", apiKeyID)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
} }
@ -106,25 +104,23 @@ func GetApiKeysHandler(store db.DB) http.HandlerFunc {
ctx := r.Context() ctx := r.Context()
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Debug().Msgf("GetApiKeysHandler: Received request with params: '%s'", r.URL.Query().Encode()) l.Debug().Msg("GetApiKeysHandler: Received request")
user := middleware.GetUserFromContext(ctx) user := middleware.GetUserFromContext(ctx)
if user == nil { if user == nil {
l.Debug().Msg("GetApiKeysHandler: Invalid user retrieved from context") l.Debug().Msg("GetApiKeysHandler: Invalid user context")
utils.WriteError(w, "unauthorized", http.StatusUnauthorized) utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return return
} }
l.Debug().Msgf("GetApiKeysHandler: Retrieving API keys for user ID: %d", user.ID)
apiKeys, err := store.GetApiKeysByUserID(ctx, user.ID) apiKeys, err := store.GetApiKeysByUserID(ctx, user.ID)
if err != nil { if err != nil {
l.Err(fmt.Errorf("GetApiKeysHandler: %w", err)).Msg("Failed to retrieve API keys") l.Error().Err(err).Msg("GetApiKeysHandler: Failed to retrieve API keys")
utils.WriteError(w, "failed to retrieve api keys", http.StatusInternalServerError) utils.WriteError(w, "failed to retrieve api keys", http.StatusInternalServerError)
return return
} }
l.Debug().Msgf("GetApiKeysHandler: Successfully retrieved %d API keys for user ID: %d", len(apiKeys), user.ID) l.Debug().Msgf("GetApiKeysHandler: Retrieved %d API keys", len(apiKeys))
utils.WriteJSON(w, http.StatusOK, apiKeys) utils.WriteJSON(w, http.StatusOK, apiKeys)
} }
} }
@ -134,45 +130,42 @@ func UpdateApiKeyLabelHandler(store db.DB) http.HandlerFunc {
ctx := r.Context() ctx := r.Context()
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Debug().Msg("UpdateApiKeyLabelHandler: Received request to update API key label") l.Debug().Msg("UpdateApiKeyLabelHandler: Received request")
user := middleware.GetUserFromContext(ctx) user := middleware.GetUserFromContext(ctx)
if user == nil { if user == nil {
l.Debug().Msg("UpdateApiKeyLabelHandler: Unauthorized request (user context is nil)") l.Debug().Msg("UpdateApiKeyLabelHandler: Invalid user context")
utils.WriteError(w, "unauthorized", http.StatusUnauthorized) utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return return
} }
idStr := r.URL.Query().Get("id") idStr := r.URL.Query().Get("id")
if idStr == "" { if idStr == "" {
l.Debug().Msg("UpdateApiKeyLabelHandler: Missing API key ID in request") l.Debug().Msg("UpdateApiKeyLabelHandler: Missing id parameter")
utils.WriteError(w, "id is required", http.StatusBadRequest) utils.WriteError(w, "id is required", http.StatusBadRequest)
return return
} }
apiKeyID, err := strconv.Atoi(idStr) apiKeyID, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
l.Debug().AnErr("error", fmt.Errorf("UpdateApiKeyLabelHandler: %w", err)).Msg("Invalid API key ID") l.Debug().AnErr("error", err).Msg("UpdateApiKeyLabelHandler: Invalid API key ID")
utils.WriteError(w, "id is invalid", http.StatusBadRequest) utils.WriteError(w, "invalid id", http.StatusBadRequest)
return return
} }
label := r.FormValue("label") label := r.FormValue("label")
if label == "" { if label == "" {
l.Debug().Msg("UpdateApiKeyLabelHandler: Missing label in request") l.Debug().Msg("UpdateApiKeyLabelHandler: Missing label parameter")
utils.WriteError(w, "label is required", http.StatusBadRequest) utils.WriteError(w, "label is required", http.StatusBadRequest)
return return
} }
l.Debug().Msgf("UpdateApiKeyLabelHandler: Updating label for API key ID %d", apiKeyID) if err := store.UpdateApiKeyLabel(ctx, db.UpdateApiKeyLabelOpts{
err = store.UpdateApiKeyLabel(ctx, db.UpdateApiKeyLabelOpts{
UserID: user.ID, UserID: user.ID,
ID: int32(apiKeyID), ID: int32(apiKeyID),
Label: label, Label: label,
}) }); err != nil {
if err != nil { l.Error().Err(err).Msg("UpdateApiKeyLabelHandler: Failed to update API key label")
l.Err(fmt.Errorf("UpdateApiKeyLabelHandler: %w", err)).Msg("Failed to update API key label")
utils.WriteError(w, "failed to update api key label", http.StatusInternalServerError) utils.WriteError(w, "failed to update api key label", http.StatusInternalServerError)
return return
} }

@ -0,0 +1,156 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/models"
"github.com/gabehf/koito/internal/utils"
)
func SetPrimaryArtistHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// sets the primary alias for albums, artists, and tracks
ctx := r.Context()
l := logger.FromContext(ctx)
l.Debug().Msg("SetPrimaryArtistHandler: Got request")
r.ParseForm()
// Parse query parameters
artistIDStr := r.FormValue("artist_id")
albumIDStr := r.FormValue("album_id")
trackIDStr := r.FormValue("track_id")
isPrimaryStr := r.FormValue("is_primary")
l.Debug().Str("query", r.Form.Encode()).Msg("Recieved form")
if artistIDStr == "" {
l.Debug().Msg("SetPrimaryArtistHandler: artist_id must be provided")
utils.WriteError(w, "artist_id must be provided", http.StatusBadRequest)
return
}
if isPrimaryStr == "" {
l.Debug().Msg("SetPrimaryArtistHandler: is_primary must be provided")
utils.WriteError(w, "is_primary must be provided", http.StatusBadRequest)
return
}
primary, ok := utils.ParseBool(isPrimaryStr)
if !ok {
l.Debug().Msg("SetPrimaryArtistHandler: is_primary must be either true or false")
utils.WriteError(w, "is_primary must be either true or false", http.StatusBadRequest)
return
}
artistId, err := strconv.Atoi(artistIDStr)
if err != nil {
l.Debug().Msg("SetPrimaryArtistHandler: artist_id is invalid")
utils.WriteError(w, "artist_id is invalid", http.StatusBadRequest)
return
}
if albumIDStr == "" && trackIDStr == "" {
l.Debug().Msg("SetPrimaryArtistHandler: Missing album or track id parameter")
utils.WriteError(w, "album_id or track_id must be provided", http.StatusBadRequest)
return
}
if utils.MoreThanOneString(albumIDStr, trackIDStr) {
l.Debug().Msg("SetPrimaryArtistHandler: Multiple ID parameters provided")
utils.WriteError(w, "only one of album_id or track_id can be provided", http.StatusBadRequest)
return
}
if albumIDStr != "" {
id, err := strconv.Atoi(albumIDStr)
if err != nil {
l.Debug().AnErr("error", err).Msg("SetPrimaryArtistHandler: Invalid album id")
utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
return
}
err = store.SetPrimaryAlbumArtist(ctx, int32(id), int32(artistId), primary)
if err != nil {
l.Error().Err(err).Msg("SetPrimaryArtistHandler: Failed to set album primary alias")
utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError)
return
}
} else if trackIDStr != "" {
id, err := strconv.Atoi(trackIDStr)
if err != nil {
l.Debug().AnErr("error", err).Msg("SetPrimaryArtistHandler: Invalid track id")
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
return
}
err = store.SetPrimaryTrackArtist(ctx, int32(id), int32(artistId), primary)
if err != nil {
l.Error().Err(err).Msg("SetPrimaryArtistHandler: Failed to set track primary alias")
utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
}
func GetArtistsForItemHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
l.Debug().Msg("GetArtistsForItemHandler: Received request to retrieve artists for item")
albumIDStr := r.URL.Query().Get("album_id")
trackIDStr := r.URL.Query().Get("track_id")
if albumIDStr == "" && trackIDStr == "" {
l.Debug().Msg("GetArtistsForItemHandler: Missing album or track ID parameter")
utils.WriteError(w, "album_id or track_id must be provided", http.StatusBadRequest)
return
}
if utils.MoreThanOneString(albumIDStr, trackIDStr) {
l.Debug().Msg("GetArtistsForItemHandler: Multiple ID parameters provided")
utils.WriteError(w, "only one of album_id or track_id can be provided", http.StatusBadRequest)
return
}
var artists []*models.Artist
var err error
if albumIDStr != "" {
albumID, convErr := strconv.Atoi(albumIDStr)
if convErr != nil {
l.Debug().AnErr("error", convErr).Msg("GetArtistsForItemHandler: Invalid album ID")
utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
return
}
l.Debug().Msgf("GetArtistsForItemHandler: Fetching artists for album ID %d", albumID)
artists, err = store.GetArtistsForAlbum(ctx, int32(albumID))
} else if trackIDStr != "" {
trackID, convErr := strconv.Atoi(trackIDStr)
if convErr != nil {
l.Debug().AnErr("error", convErr).Msg("GetArtistsForItemHandler: Invalid track ID")
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
return
}
l.Debug().Msgf("GetArtistsForItemHandler: Fetching artists for track ID %d", trackID)
artists, err = store.GetArtistsForTrack(ctx, int32(trackID))
}
if err != nil {
l.Err(err).Msg("GetArtistsForItemHandler: Failed to retrieve artists")
utils.WriteError(w, "failed to retrieve artists", http.StatusInternalServerError)
return
}
l.Debug().Msg("GetArtistsForItemHandler: Successfully retrieved artists")
utils.WriteJSON(w, http.StatusOK, artists)
}
}

@ -18,70 +18,62 @@ func LoginHandler(store db.DB) http.HandlerFunc {
ctx := r.Context() ctx := r.Context()
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Debug().Msg("LoginHandler: Received login request") l.Debug().Msg("LoginHandler: Received request")
err := r.ParseForm() if err := r.ParseForm(); err != nil {
if err != nil { l.Debug().AnErr("error", err).Msg("LoginHandler: Failed to parse form")
l.Debug().Msg("LoginHandler: Failed to parse request form") utils.WriteError(w, "invalid request format", http.StatusBadRequest)
utils.WriteError(w, "failed to parse request", http.StatusInternalServerError)
return return
} }
username := r.FormValue("username") username := r.FormValue("username")
password := r.FormValue("password") password := r.FormValue("password")
if username == "" || password == "" { if username == "" || password == "" {
l.Debug().Msg("LoginHandler: Missing username or password") l.Debug().Msg("LoginHandler: Missing credentials")
utils.WriteError(w, "username and password are required", http.StatusBadRequest) utils.WriteError(w, "username and password required", http.StatusBadRequest)
return return
} }
l.Debug().Msgf("LoginHandler: Searching for user with username '%s'", username)
user, err := store.GetUserByUsername(ctx, username) user, err := store.GetUserByUsername(ctx, username)
if err != nil { if err != nil {
l.Err(err).Msg("LoginHandler: Error searching for user in database") l.Error().Err(err).Msg("LoginHandler: Database error fetching user")
utils.WriteError(w, "internal server error", http.StatusInternalServerError) utils.WriteError(w, "authentication failed", http.StatusInternalServerError)
return return
} else if user == nil { }
l.Debug().Msg("LoginHandler: Username or password is incorrect") if user == nil {
utils.WriteError(w, "username or password is incorrect", http.StatusBadRequest) l.Debug().Msg("LoginHandler: User not found")
utils.WriteError(w, "invalid credentials", http.StatusUnauthorized)
return return
} }
err = bcrypt.CompareHashAndPassword(user.Password, []byte(password)) if err := bcrypt.CompareHashAndPassword(user.Password, []byte(password)); err != nil {
if err != nil { l.Debug().Msg("LoginHandler: Invalid password")
l.Debug().Msg("LoginHandler: Password comparison failed") utils.WriteError(w, "invalid credentials", http.StatusUnauthorized)
utils.WriteError(w, "username or password is incorrect", http.StatusBadRequest)
return return
} }
keepSignedIn := false expiresAt := time.Now().Add(24 * time.Hour)
expiresAt := time.Now().Add(1 * 24 * time.Hour)
if strings.ToLower(r.FormValue("remember_me")) == "true" { if strings.ToLower(r.FormValue("remember_me")) == "true" {
keepSignedIn = true
expiresAt = time.Now().Add(30 * 24 * time.Hour) expiresAt = time.Now().Add(30 * 24 * time.Hour)
} }
l.Debug().Msgf("LoginHandler: Creating session for user ID %d", user.ID) session, err := store.SaveSession(ctx, user.ID, expiresAt, r.FormValue("remember_me") == "true")
session, err := store.SaveSession(ctx, user.ID, expiresAt, keepSignedIn)
if err != nil { if err != nil {
l.Err(err).Msg("LoginHandler: Failed to create session") l.Error().Err(err).Msg("LoginHandler: Failed to create session")
utils.WriteError(w, "failed to create session", http.StatusInternalServerError) utils.WriteError(w, "authentication failed", http.StatusInternalServerError)
return return
} }
cookie := &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "koito_session", Name: "koito_session",
Value: session.ID.String(), Value: session.ID.String(),
Expires: expiresAt,
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
Secure: false, Secure: false,
} })
if keepSignedIn {
cookie.Expires = expiresAt
}
l.Debug().Msgf("LoginHandler: Session created successfully for user ID %d", user.ID) l.Debug().Msgf("LoginHandler: User %d authenticated", user.ID)
http.SetCookie(w, cookie)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
} }
@ -91,34 +83,27 @@ func LogoutHandler(store db.DB) http.HandlerFunc {
ctx := r.Context() ctx := r.Context()
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Debug().Msg("LogoutHandler: Received logout request") l.Debug().Msg("LogoutHandler: Received request")
cookie, err := r.Cookie("koito_session") cookie, err := r.Cookie("koito_session")
if err == nil { if err == nil {
l.Debug().Msg("LogoutHandler: Found session cookie")
sid, err := uuid.Parse(cookie.Value) sid, err := uuid.Parse(cookie.Value)
if err != nil { if err != nil {
l.Debug().AnErr("error", err).Msg("LogoutHandler: Invalid session cookie") l.Debug().AnErr("error", err).Msg("LogoutHandler: Invalid session ID")
utils.WriteError(w, "session cookie is invalid", http.StatusUnauthorized) } else if err := store.DeleteSession(ctx, sid); err != nil {
return l.Error().Err(err).Msg("LogoutHandler: Failed to delete session")
}
l.Debug().Msgf("LogoutHandler: Deleting session with ID %s", sid)
err = store.DeleteSession(ctx, sid)
if err != nil {
l.Err(err).Msg("LogoutHandler: Failed to delete session")
utils.WriteError(w, "internal server error", http.StatusInternalServerError)
return
} }
} }
l.Debug().Msg("LogoutHandler: Clearing session cookie")
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "koito_session", Name: "koito_session",
Value: "", Value: "",
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
MaxAge: -1, // expire immediately MaxAge: -1,
}) })
l.Debug().Msg("LogoutHandler: Session terminated")
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
} }
@ -128,16 +113,17 @@ func MeHandler(store db.DB) http.HandlerFunc {
ctx := r.Context() ctx := r.Context()
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Debug().Msg("MeHandler: Received request to retrieve user information") l.Debug().Msg("MeHandler: Received request")
u := middleware.GetUserFromContext(ctx)
if u == nil { user := middleware.GetUserFromContext(ctx)
l.Debug().Msg("MeHandler: Invalid user retrieved from context") if user == nil {
l.Debug().Msg("MeHandler: Unauthorized access")
utils.WriteError(w, "unauthorized", http.StatusUnauthorized) utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return return
} }
l.Debug().Msgf("MeHandler: Successfully retrieved user with ID %d", u.ID) l.Debug().Msgf("MeHandler: Returning user data for ID %d", user.ID)
utils.WriteJSON(w, http.StatusOK, u) utils.WriteJSON(w, http.StatusOK, user)
} }
} }
@ -146,41 +132,42 @@ func UpdateUserHandler(store db.DB) http.HandlerFunc {
ctx := r.Context() ctx := r.Context()
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Debug().Msg("UpdateUserHandler: Received request to update user information") l.Debug().Msg("UpdateUserHandler: Received request")
u := middleware.GetUserFromContext(ctx)
if u == nil { user := middleware.GetUserFromContext(ctx)
l.Debug().Msg("UpdateUserHandler: Unauthorized request (user context is nil)") if user == nil {
l.Debug().Msg("UpdateUserHandler: Unauthorized access")
utils.WriteError(w, "unauthorized", http.StatusUnauthorized) utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return return
} }
err := r.ParseForm() if err := r.ParseForm(); err != nil {
if err != nil { l.Error().Err(err).Msg("UpdateUserHandler: Invalid form data")
l.Err(err).Msg("UpdateUserHandler: Failed to parse request form") utils.WriteError(w, "invalid request", http.StatusBadRequest)
utils.WriteError(w, "failed to parse request", http.StatusInternalServerError)
return return
} }
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" && password == "" { opts := db.UpdateUserOpts{ID: user.ID}
l.Debug().Msg("UpdateUserHandler: No parameters were recieved") if username := r.FormValue("username"); username != "" {
utils.WriteError(w, "all parameters missing", http.StatusBadRequest) opts.Username = username
}
if password := r.FormValue("password"); password != "" {
opts.Password = password
}
if opts.Username == "" && opts.Password == "" {
l.Debug().Msg("UpdateUserHandler: No update parameters provided")
utils.WriteError(w, "no changes specified", http.StatusBadRequest)
return return
} }
l.Debug().Msgf("UpdateUserHandler: Updating user with ID %d", u.ID)
err = store.UpdateUser(ctx, db.UpdateUserOpts{ if err := store.UpdateUser(ctx, opts); err != nil {
ID: u.ID, l.Error().Err(err).Msg("UpdateUserHandler: Update failed")
Username: username, utils.WriteError(w, "update failed", http.StatusBadRequest)
Password: password,
})
if err != nil {
l.Err(err).Msg("UpdateUserHandler: Failed to update user")
utils.WriteError(w, err.Error(), http.StatusBadRequest)
return return
} }
l.Debug().Msgf("UpdateUserHandler: Successfully updated user with ID %d", u.ID) l.Debug().Msgf("UpdateUserHandler: User %d updated", user.ID)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
} }

@ -10,7 +10,6 @@ import (
"github.com/gabehf/koito/internal/utils" "github.com/gabehf/koito/internal/utils"
) )
// DeleteTrackHandler deletes a track by its ID.
func DeleteTrackHandler(store db.DB) http.HandlerFunc { func DeleteTrackHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@ -46,7 +45,6 @@ func DeleteTrackHandler(store db.DB) http.HandlerFunc {
} }
} }
// DeleteListenHandler deletes a listen record by track ID and timestamp.
func DeleteListenHandler(store db.DB) http.HandlerFunc { func DeleteListenHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@ -96,7 +94,6 @@ func DeleteListenHandler(store db.DB) http.HandlerFunc {
} }
} }
// DeleteArtistHandler deletes an artist by its ID.
func DeleteArtistHandler(store db.DB) http.HandlerFunc { func DeleteArtistHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@ -132,7 +129,6 @@ func DeleteArtistHandler(store db.DB) http.HandlerFunc {
} }
} }
// DeleteAlbumHandler deletes an album by its ID.
func DeleteAlbumHandler(store db.DB) http.HandlerFunc { func DeleteAlbumHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()

@ -117,7 +117,12 @@ func serveDefaultImage(w http.ResponseWriter, r *http.Request, size catalog.Imag
return return
} }
lock.Lock() lock.Lock()
utils.CopyFile(path.Join("assets", "default_img"), defaultImagePath) err = utils.CopyFile(path.Join("assets", "default_img"), defaultImagePath)
if err != nil {
l.Err(err).Msg("serveDefaultImage: Error when copying default image from assets")
w.WriteHeader(http.StatusInternalServerError)
return
}
lock.Unlock() lock.Unlock()
} else if err != nil { } else if err != nil {
l.Err(err).Msg("serveDefaultImage: Error when attempting to read default image in cache") l.Err(err).Msg("serveDefaultImage: Error when attempting to read default image in cache")
@ -151,7 +156,7 @@ func serveDefaultImage(w http.ResponseWriter, r *http.Request, size catalog.Imag
func downloadMissingImage(ctx context.Context, store db.DB, id uuid.UUID) (string, error) { func downloadMissingImage(ctx context.Context, store db.DB, id uuid.UUID) (string, error) {
src, err := store.GetImageSource(ctx, id) src, err := store.GetImageSource(ctx, id)
if err != nil { if err != nil {
return "", fmt.Errorf("downloadMissingImage: store.GetImageSource: %w", err) return "", fmt.Errorf("downloadMissingImage: %w", err)
} }
var size catalog.ImageSize var size catalog.ImageSize
if cfg.FullImageCacheEnabled() { if cfg.FullImageCacheEnabled() {
@ -161,7 +166,7 @@ func downloadMissingImage(ctx context.Context, store db.DB, id uuid.UUID) (strin
} }
err = catalog.DownloadAndCacheImage(ctx, id, src, size) err = catalog.DownloadAndCacheImage(ctx, id, src, size)
if err != nil { if err != nil {
return "", fmt.Errorf("downloadMissingImage: catalog.DownloadAndCacheImage: %w", err) return "", fmt.Errorf("downloadMissingImage: %w", err)
} }
return path.Join(catalog.SourceImageDir(), id.String()), nil return path.Join(catalog.SourceImageDir(), id.String()), nil
} }

@ -137,13 +137,13 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
artistMbzIDs, err := utils.ParseUUIDSlice(payload.TrackMeta.AdditionalInfo.ArtistMBIDs) artistMbzIDs, err := utils.ParseUUIDSlice(payload.TrackMeta.AdditionalInfo.ArtistMBIDs)
if err != nil { if err != nil {
l.Debug().Err(err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs") l.Debug().AnErr("error", err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs")
} }
if len(artistMbzIDs) < 1 { if len(artistMbzIDs) < 1 {
l.Debug().Err(err).Msg("LbzSubmitListenHandler: Attempting to parse artist UUIDs from mbid_mapping") l.Debug().AnErr("error", err).Msg("LbzSubmitListenHandler: Attempting to parse artist UUIDs from mbid_mapping")
utils.ParseUUIDSlice(payload.TrackMeta.MBIDMapping.ArtistMBIDs) utils.ParseUUIDSlice(payload.TrackMeta.MBIDMapping.ArtistMBIDs)
if err != nil { if err != nil {
l.Debug().Err(err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs") l.Debug().AnErr("error", err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs")
} }
} }
rgMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseGroupMBID) rgMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseGroupMBID)
@ -191,7 +191,7 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
} }
mbid, err := uuid.Parse(a.ArtistMBID) mbid, err := uuid.Parse(a.ArtistMBID)
if err != nil { if err != nil {
l.Err(err).Msgf("LbzSubmitListenHandler: Failed to parse UUID for artist '%s'", a.ArtistName) l.Debug().AnErr("error", err).Msgf("LbzSubmitListenHandler: Failed to parse UUID for artist '%s'", a.ArtistName)
} }
artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: a.ArtistName, Mbid: mbid}) artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: a.ArtistName, Mbid: mbid})
} }

@ -53,6 +53,7 @@ func makeAuthRequest(t *testing.T, session, method, endpoint string, body io.Rea
Name: "koito_session", Name: "koito_session",
Value: session, Value: session,
}) })
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
t.Logf("Making request to %s with session: %s", endpoint, session) t.Logf("Making request to %s with session: %s", endpoint, session)
return http.DefaultClient.Do(req) return http.DefaultClient.Do(req)
} }
@ -512,7 +513,7 @@ func TestAuth(t *testing.T) {
encoded = formdata.Encode() encoded = formdata.Encode()
resp, err = http.DefaultClient.Post(host()+"/apis/web/v1/login", "application/x-www-form-urlencoded", strings.NewReader(encoded)) resp, err = http.DefaultClient.Post(host()+"/apis/web/v1/login", "application/x-www-form-urlencoded", strings.NewReader(encoded))
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 400, resp.StatusCode) require.Equal(t, 401, resp.StatusCode)
// reset update so other tests dont fail // reset update so other tests dont fail
req, err = http.NewRequest("PATCH", host()+fmt.Sprintf("/apis/web/v1/user?username=%s&password=%s", cfg.DefaultUsername(), cfg.DefaultPassword()), nil) req, err = http.NewRequest("PATCH", host()+fmt.Sprintf("/apis/web/v1/user?username=%s&password=%s", cfg.DefaultUsername(), cfg.DefaultPassword()), nil)
@ -732,3 +733,160 @@ func TestAlbumReplaceImage(t *testing.T) {
assert.NotNil(t, a.Image) assert.NotNil(t, a.Image)
assert.Equal(t, newid, *a.Image) assert.Equal(t, newid, *a.Image)
} }
func TestSetPrimaryArtist(t *testing.T) {
t.Run("Submit Listens", doSubmitListens)
ctx := context.Background()
// set and unset track primary artist
formdata := url.Values{}
formdata.Set("artist_id", "1")
formdata.Set("track_id", "1")
formdata.Set("is_primary", "false")
body := formdata.Encode()
resp, err := makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
require.NoError(t, err)
require.Equal(t, 204, resp.StatusCode)
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artist_tracks
WHERE track_id = $1 AND artist_id = $2 AND is_primary = $3
)`, 1, 1, false)
require.NoError(t, err)
assert.True(t, exists, "expected artist is_primary to be false")
formdata = url.Values{}
formdata.Set("artist_id", "1")
formdata.Set("track_id", "1")
formdata.Set("is_primary", "true")
body = formdata.Encode()
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
require.NoError(t, err)
require.Equal(t, 204, resp.StatusCode)
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artist_tracks
WHERE track_id = $1 AND artist_id = $2 AND is_primary = $3
)`, 1, 1, true)
require.NoError(t, err)
assert.True(t, exists, "expected artist is_primary to be true")
// set and unset album primary artist
formdata = url.Values{}
formdata.Set("artist_id", "1")
formdata.Set("album_id", "1")
formdata.Set("is_primary", "false")
body = formdata.Encode()
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
require.NoError(t, err)
require.Equal(t, 204, resp.StatusCode)
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artist_releases
WHERE release_id = $1 AND artist_id = $2 AND is_primary = $3
)`, 1, 1, false)
require.NoError(t, err)
assert.True(t, exists, "expected artist is_primary to be false")
formdata = url.Values{}
formdata.Set("artist_id", "1")
formdata.Set("album_id", "1")
formdata.Set("is_primary", "true")
body = formdata.Encode()
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
require.NoError(t, err)
require.Equal(t, 204, resp.StatusCode)
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artist_releases
WHERE release_id = $1 AND artist_id = $2 AND is_primary = $3
)`, 1, 1, true)
require.NoError(t, err)
assert.True(t, exists, "expected artist is_primary to be true")
// create a new track with multiple artists to make sure only one is primary at a time
listenBody := `{
"listen_type": "single",
"payload": [
{
"listened_at": 1749475719,
"track_metadata": {
"additional_info": {
"artist_names": [
"Rat Tally",
"Madeline Kenney"
],
"duration_ms": 197270,
"submission_client": "navidrome",
"submission_client_version": "0.56.1 (fa2cf362)"
},
"artist_name": "Rat Tally feat. Madeline Kenney",
"release_name": "In My Car",
"track_name": "In My Car"
}
}
]
}`
req, err := http.NewRequest("POST", host()+"/apis/listenbrainz/1/submit-listens", strings.NewReader(listenBody))
require.NoError(t, err)
req.Header.Add("Authorization", fmt.Sprintf("Token %s", apikey))
req.Header.Add("Content-Type", "application/json")
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
respBytes, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, `{"status": "ok"}`, string(respBytes))
// set both artists as primary
formdata = url.Values{}
formdata.Set("artist_id", "4")
formdata.Set("album_id", "4")
formdata.Set("is_primary", "true")
body = formdata.Encode()
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
require.NoError(t, err)
require.Equal(t, 204, resp.StatusCode)
formdata = url.Values{}
formdata.Set("artist_id", "5")
formdata.Set("album_id", "4")
formdata.Set("is_primary", "true")
body = formdata.Encode()
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
require.NoError(t, err)
require.Equal(t, 204, resp.StatusCode)
formdata = url.Values{}
formdata.Set("artist_id", "4")
formdata.Set("track_id", "4")
formdata.Set("is_primary", "true")
body = formdata.Encode()
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
require.NoError(t, err)
require.Equal(t, 204, resp.StatusCode)
formdata = url.Values{}
formdata.Set("artist_id", "5")
formdata.Set("track_id", "4")
formdata.Set("is_primary", "true")
body = formdata.Encode()
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
require.NoError(t, err)
require.Equal(t, 204, resp.StatusCode)
count, err := store.Count(ctx, `SELECT COUNT(*) FROM artist_releases WHERE release_id = $1 AND is_primary = $2`, 4, true)
require.NoError(t, err)
assert.EqualValues(t, 1, count, "expected only one primary artist for release")
count, err = store.Count(ctx, `SELECT COUNT(*) FROM artist_tracks WHERE track_id = $1 AND is_primary = $2`, 4, true)
require.NoError(t, err)
assert.EqualValues(t, 1, count, "expected only one primary artist for track")
}

@ -36,6 +36,7 @@ func bindRoutes(
r.Route("/apis/web/v1", func(r chi.Router) { r.Route("/apis/web/v1", func(r chi.Router) {
r.Get("/artist", handlers.GetArtistHandler(db)) r.Get("/artist", handlers.GetArtistHandler(db))
r.Get("/artists", handlers.GetArtistsForItemHandler(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))
r.Get("/top-tracks", handlers.GetTopTracksHandler(db)) r.Get("/top-tracks", handlers.GetTopTracksHandler(db))
@ -75,6 +76,7 @@ func bindRoutes(
r.Post("/merge/albums", handlers.MergeReleaseGroupsHandler(db)) r.Post("/merge/albums", handlers.MergeReleaseGroupsHandler(db))
r.Post("/merge/artists", handlers.MergeArtistsHandler(db)) r.Post("/merge/artists", handlers.MergeArtistsHandler(db))
r.Delete("/artist", handlers.DeleteArtistHandler(db)) r.Delete("/artist", handlers.DeleteArtistHandler(db))
r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db))
r.Delete("/album", handlers.DeleteAlbumHandler(db)) r.Delete("/album", handlers.DeleteAlbumHandler(db))
r.Delete("/track", handlers.DeleteTrackHandler(db)) r.Delete("/track", handlers.DeleteTrackHandler(db))
r.Delete("/listen", handlers.DeleteListenHandler(db)) r.Delete("/listen", handlers.DeleteListenHandler(db))

@ -3,6 +3,7 @@ package catalog
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"slices" "slices"
"github.com/gabehf/koito/internal/cfg" "github.com/gabehf/koito/internal/cfg"
@ -23,12 +24,13 @@ type AssociateAlbumOpts struct {
ReleaseName string ReleaseName string
TrackName string // required TrackName string // required
Mbzc mbz.MusicBrainzCaller Mbzc mbz.MusicBrainzCaller
SkipCacheImage bool
} }
func AssociateAlbum(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) { func AssociateAlbum(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
if opts.TrackName == "" { if opts.TrackName == "" {
return nil, errors.New("required parameter TrackName missing") return nil, errors.New("AssociateAlbum: required parameter TrackName missing")
} }
releaseTitle := opts.ReleaseName releaseTitle := opts.ReleaseName
if releaseTitle == "" { if releaseTitle == "" {
@ -56,7 +58,7 @@ func matchAlbumByMbzReleaseID(ctx context.Context, d db.DB, opts AssociateAlbumO
Image: a.Image, Image: a.Image,
}, nil }, nil
} else if !errors.Is(err, pgx.ErrNoRows) { } else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err return nil, fmt.Errorf("matchAlbumByMbzReleaseID: %w", err)
} else { } else {
l.Debug().Msgf("Album '%s' could not be found by MusicBrainz Release ID", opts.ReleaseName) l.Debug().Msgf("Album '%s' could not be found by MusicBrainz Release ID", opts.ReleaseName)
rg, err := createOrUpdateAlbumWithMbzReleaseID(ctx, d, opts) rg, err := createOrUpdateAlbumWithMbzReleaseID(ctx, d, opts)
@ -69,14 +71,17 @@ func matchAlbumByMbzReleaseID(ctx context.Context, d db.DB, opts AssociateAlbumO
func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) { func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
release, err := opts.Mbzc.GetRelease(ctx, opts.ReleaseMbzID) release, err := opts.Mbzc.GetRelease(ctx, opts.ReleaseMbzID)
if err != nil { if err != nil {
l.Warn().Msg("MusicBrainz unreachable, falling back to release title matching") l.Warn().Msg("createOrUpdateAlbumWithMbzReleaseID: MusicBrainz unreachable, falling back to release title matching")
return matchAlbumByTitle(ctx, d, opts) return matchAlbumByTitle(ctx, d, opts)
} }
var album *models.Album var album *models.Album
titles := []string{release.Title, opts.ReleaseName} titles := []string{release.Title, opts.ReleaseName}
utils.Unique(&titles) utils.Unique(&titles)
l.Debug().Msgf("Searching for albums '%v' from artist id %d in DB", titles, opts.Artists[0].ID) l.Debug().Msgf("Searching for albums '%v' from artist id %d in DB", titles, opts.Artists[0].ID)
album, err = d.GetAlbum(ctx, db.GetAlbumOpts{ album, err = d.GetAlbum(ctx, db.GetAlbumOpts{
ArtistID: opts.Artists[0].ID, ArtistID: opts.Artists[0].ID,
@ -89,27 +94,29 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso
MusicBrainzID: opts.ReleaseMbzID, MusicBrainzID: opts.ReleaseMbzID,
}) })
if err != nil { if err != nil {
l.Err(err).Msg("Failed to update album with MusicBrainz Release ID") l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to update album with MusicBrainz Release ID")
return nil, err return nil, fmt.Errorf("createOrUpdateAlbumWithMbzReleaseID: %w", err)
} }
l.Debug().Msgf("Updated album '%s' with MusicBrainz Release ID", album.Title) l.Debug().Msgf("Updated album '%s' with MusicBrainz Release ID", album.Title)
if opts.ReleaseGroupMbzID != uuid.Nil { if opts.ReleaseGroupMbzID != uuid.Nil {
aliases, err := opts.Mbzc.GetReleaseTitles(ctx, opts.ReleaseGroupMbzID) aliases, err := opts.Mbzc.GetReleaseTitles(ctx, opts.ReleaseGroupMbzID)
if err == nil { if err == nil {
l.Debug().Msgf("Associating aliases '%s' with Release '%s'", aliases, album.Title) l.Debug().Msgf("Associating aliases '%s' with Release '%s'", aliases, album.Title)
err = d.SaveAlbumAliases(ctx, album.ID, aliases, "MusicBrainz") err = d.SaveAlbumAliases(ctx, album.ID, aliases, "MusicBrainz")
if err != nil { if err != nil {
l.Err(err).Msg("Failed to save aliases") l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to save aliases")
} }
} else { } else {
l.Info().AnErr("err", err).Msg("Failed to get release group from MusicBrainz") l.Info().AnErr("err", err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to get release group from MusicBrainz")
} }
} }
} else if !errors.Is(err, pgx.ErrNoRows) { } else if !errors.Is(err, pgx.ErrNoRows) {
l.Err(err).Msg("Error while searching for album by MusicBrainz Release ID") l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: error while searching for album by MusicBrainz Release ID")
return nil, err return nil, fmt.Errorf("createOrUpdateAlbumWithMbzReleaseID: %w", err)
} else { } else {
l.Debug().Msgf("Album %s could not be found. Creating...", release.Title) l.Debug().Msgf("Album %s could not be found. Creating...", release.Title)
var variousArtists bool var variousArtists bool
for _, artistCredit := range release.ArtistCredit { for _, artistCredit := range release.ArtistCredit {
if artistCredit.Name == "Various Artists" { if artistCredit.Name == "Various Artists" {
@ -117,6 +124,7 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso
variousArtists = true variousArtists = true
} }
} }
l.Debug().Msg("Searching for album images...") l.Debug().Msg("Searching for album images...")
var imgid uuid.UUID var imgid uuid.UUID
imgUrl, err := images.GetAlbumImage(ctx, images.AlbumImageOpts{ imgUrl, err := images.GetAlbumImage(ctx, images.AlbumImageOpts{
@ -124,23 +132,28 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso
Album: release.Title, Album: release.Title,
ReleaseMbzID: &opts.ReleaseMbzID, ReleaseMbzID: &opts.ReleaseMbzID,
}) })
if err == nil && imgUrl != "" { if err == nil && imgUrl != "" {
imgid = uuid.New()
if !opts.SkipCacheImage {
var size ImageSize var size ImageSize
if cfg.FullImageCacheEnabled() { if cfg.FullImageCacheEnabled() {
size = ImageSizeFull size = ImageSizeFull
} else { } else {
size = ImageSizeLarge size = ImageSizeLarge
} }
imgid = uuid.New()
l.Debug().Msg("Downloading album image from source...") l.Debug().Msg("Downloading album image from source...")
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size) err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to cache image") l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to cache image")
} }
} }
}
if err != nil { if err != nil {
l.Debug().Msgf("Failed to get album images for %s: %s", release.Title, err.Error()) l.Debug().Msgf("createOrUpdateAlbumWithMbzReleaseID: failed to get album images for %s: %s", release.Title, err.Error())
} }
album, err = d.SaveAlbum(ctx, db.SaveAlbumOpts{ album, err = d.SaveAlbum(ctx, db.SaveAlbumOpts{
Title: release.Title, Title: release.Title,
MusicBrainzID: opts.ReleaseMbzID, MusicBrainzID: opts.ReleaseMbzID,
@ -150,22 +163,25 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso
ImageSrc: imgUrl, ImageSrc: imgUrl,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("createOrUpdateAlbumWithMbzReleaseID: %w", err)
} }
if opts.ReleaseGroupMbzID != uuid.Nil { if opts.ReleaseGroupMbzID != uuid.Nil {
aliases, err := opts.Mbzc.GetReleaseTitles(ctx, opts.ReleaseGroupMbzID) aliases, err := opts.Mbzc.GetReleaseTitles(ctx, opts.ReleaseGroupMbzID)
if err == nil { if err == nil {
l.Debug().Msgf("Associating aliases '%s' with Release '%s'", aliases, album.Title) l.Debug().Msgf("Associating aliases '%s' with Release '%s'", aliases, album.Title)
err = d.SaveAlbumAliases(ctx, album.ID, aliases, "MusicBrainz") err = d.SaveAlbumAliases(ctx, album.ID, aliases, "MusicBrainz")
if err != nil { if err != nil {
l.Err(err).Msg("Failed to save aliases") l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to save aliases")
} }
} else { } else {
l.Info().AnErr("err", err).Msg("Failed to get release group from MusicBrainz") l.Info().AnErr("err", err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to get release group from MusicBrainz")
} }
} }
l.Info().Msgf("Created album '%s' with MusicBrainz Release ID", album.Title) l.Info().Msgf("Created album '%s' with MusicBrainz Release ID", album.Title)
} }
return &models.Album{ return &models.Album{
ID: album.ID, ID: album.ID,
MbzID: &opts.ReleaseMbzID, MbzID: &opts.ReleaseMbzID,
@ -176,12 +192,14 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso
func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) { func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
var releaseName string var releaseName string
if opts.ReleaseName != "" { if opts.ReleaseName != "" {
releaseName = opts.ReleaseName releaseName = opts.ReleaseName
} else { } else {
releaseName = opts.TrackName releaseName = opts.TrackName
} }
a, err := d.GetAlbum(ctx, db.GetAlbumOpts{ a, err := d.GetAlbum(ctx, db.GetAlbumOpts{
Title: releaseName, Title: releaseName,
ArtistID: opts.Artists[0].ID, ArtistID: opts.Artists[0].ID,
@ -195,11 +213,11 @@ func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*
MusicBrainzID: opts.ReleaseMbzID, MusicBrainzID: opts.ReleaseMbzID,
}) })
if err != nil { if err != nil {
l.Err(err).Msg("Failed to associate existing release with MusicBrainz ID") l.Err(err).Msg("matchAlbumByTitle: failed to associate existing release with MusicBrainz ID")
} }
} }
} else if !errors.Is(err, pgx.ErrNoRows) { } else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err return nil, fmt.Errorf("matchAlbumByTitle: %w", err)
} else { } else {
var imgid uuid.UUID var imgid uuid.UUID
imgUrl, err := images.GetAlbumImage(ctx, images.AlbumImageOpts{ imgUrl, err := images.GetAlbumImage(ctx, images.AlbumImageOpts{
@ -208,22 +226,25 @@ func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*
ReleaseMbzID: &opts.ReleaseMbzID, ReleaseMbzID: &opts.ReleaseMbzID,
}) })
if err == nil && imgUrl != "" { if err == nil && imgUrl != "" {
imgid = uuid.New()
if !opts.SkipCacheImage {
var size ImageSize var size ImageSize
if cfg.FullImageCacheEnabled() { if cfg.FullImageCacheEnabled() {
size = ImageSizeFull size = ImageSizeFull
} else { } else {
size = ImageSizeLarge size = ImageSizeLarge
} }
imgid = uuid.New()
l.Debug().Msg("Downloading album image from source...") l.Debug().Msg("Downloading album image from source...")
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size) err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to cache image") l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to cache image")
}
} }
} }
if err != nil { if err != nil {
l.Debug().Msgf("Failed to get album images for %s: %s", opts.ReleaseName, err.Error()) l.Debug().AnErr("error", err).Msgf("matchAlbumByTitle: failed to get album images for %s", opts.ReleaseName)
} }
a, err = d.SaveAlbum(ctx, db.SaveAlbumOpts{ a, err = d.SaveAlbum(ctx, db.SaveAlbumOpts{
Title: releaseName, Title: releaseName,
ArtistIDs: utils.FlattenArtistIDs(opts.Artists), ArtistIDs: utils.FlattenArtistIDs(opts.Artists),
@ -232,10 +253,11 @@ func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*
ImageSrc: imgUrl, ImageSrc: imgUrl,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("matchAlbumByTitle: %w", err)
} }
l.Info().Msgf("Created album '%s' with artist and title", a.Title) l.Info().Msgf("Created album '%s' with artist and title", a.Title)
} }
return &models.Album{ return &models.Album{
ID: a.ID, ID: a.ID,
Title: a.Title, Title: a.Title,

@ -24,6 +24,8 @@ type AssociateArtistsOpts struct {
ArtistName string ArtistName string
TrackTitle string TrackTitle string
Mbzc mbz.MusicBrainzCaller Mbzc mbz.MusicBrainzCaller
SkipCacheImage bool
} }
func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) { func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) {
@ -36,7 +38,7 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
l.Debug().Msg("Associating artists by MusicBrainz ID(s) mappings") l.Debug().Msg("Associating artists by MusicBrainz ID(s) mappings")
mbzMatches, err := matchArtistsByMBIDMappings(ctx, d, opts) mbzMatches, err := matchArtistsByMBIDMappings(ctx, d, opts)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("AssociateArtists: %w", err)
} }
result = append(result, mbzMatches...) result = append(result, mbzMatches...)
} }
@ -45,16 +47,16 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
l.Debug().Msg("Associating artists by list of MusicBrainz ID(s)") l.Debug().Msg("Associating artists by list of MusicBrainz ID(s)")
mbzMatches, err := matchArtistsByMBID(ctx, d, opts, result) mbzMatches, err := matchArtistsByMBID(ctx, d, opts, result)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("AssociateArtists: %w", err)
} }
result = append(result, mbzMatches...) result = append(result, mbzMatches...)
} }
if len(opts.ArtistNames) > len(result) { if len(opts.ArtistNames) > len(result) {
l.Debug().Msg("Associating artists by list of artist names") l.Debug().Msg("Associating artists by list of artist names")
nameMatches, err := matchArtistsByNames(ctx, opts.ArtistNames, result, d) nameMatches, err := matchArtistsByNames(ctx, opts.ArtistNames, result, d, opts)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("AssociateArtists: %w", err)
} }
result = append(result, nameMatches...) result = append(result, nameMatches...)
} }
@ -62,9 +64,9 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
if len(result) < 1 { if len(result) < 1 {
allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle)) allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
l.Debug().Msgf("Associating artists by artist name(s) %v and track title '%s'", allArtists, opts.TrackTitle) l.Debug().Msgf("Associating artists by artist name(s) %v and track title '%s'", allArtists, opts.TrackTitle)
fallbackMatches, err := matchArtistsByNames(ctx, allArtists, nil, d) fallbackMatches, err := matchArtistsByNames(ctx, allArtists, nil, d, opts)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("AssociateArtists: %w", err)
} }
result = append(result, fallbackMatches...) result = append(result, fallbackMatches...)
} }
@ -77,7 +79,6 @@ func matchArtistsByMBIDMappings(ctx context.Context, d db.DB, opts AssociateArti
var result []*models.Artist var result []*models.Artist
for _, a := range opts.ArtistMbidMap { for _, a := range opts.ArtistMbidMap {
// first, try to get by mbid
artist, err := d.GetArtist(ctx, db.GetArtistOpts{ artist, err := d.GetArtist(ctx, db.GetArtistOpts{
MusicBrainzID: a.Mbid, MusicBrainzID: a.Mbid,
}) })
@ -87,18 +88,17 @@ func matchArtistsByMBIDMappings(ctx context.Context, d db.DB, opts AssociateArti
continue continue
} }
if !errors.Is(err, pgx.ErrNoRows) { if !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("matchArtistsBYMBIDMappings: %w", err) return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err)
} }
// then, try to get by mbz name
artist, err = d.GetArtist(ctx, db.GetArtistOpts{ artist, err = d.GetArtist(ctx, db.GetArtistOpts{
Name: a.Artist, Name: a.Artist,
}) })
if err == nil { if err == nil {
l.Debug().Msgf("Artist '%s' found by Name", a.Artist) l.Debug().Msgf("Artist '%s' found by Name", a.Artist)
// ...associate with mbzid if found
err = d.UpdateArtist(ctx, db.UpdateArtistOpts{ID: artist.ID, MusicBrainzID: a.Mbid}) err = d.UpdateArtist(ctx, db.UpdateArtistOpts{ID: artist.ID, MusicBrainzID: a.Mbid})
if err != nil { if err != nil {
l.Err(fmt.Errorf("matchArtistsBYMBIDMappings: %w", err)).Msgf("Failed to associate artist '%s' with MusicBrainz ID", artist.Name) l.Err(err).Msgf("matchArtistsByMBIDMappings: Failed to associate artist '%s' with MusicBrainz ID", artist.Name)
} else { } else {
artist.MbzID = &a.Mbid artist.MbzID = &a.Mbid
} }
@ -106,36 +106,51 @@ func matchArtistsByMBIDMappings(ctx context.Context, d db.DB, opts AssociateArti
continue continue
} }
if !errors.Is(err, pgx.ErrNoRows) { if !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("matchArtistsBYMBIDMappings: %w", err) return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err)
} }
// then, try to get by aliases, or create artist, err = resolveAliasOrCreateArtist(ctx, a.Mbid, opts.ArtistNames, d, opts)
artist, err = resolveAliasOrCreateArtist(ctx, a.Mbid, opts.ArtistNames, d, opts.Mbzc)
if err != nil { if err != nil {
// if mbz unreachable, just create a new artist with provided name and mbid l.Warn().AnErr("error", err).Msg("matchArtistsByMBIDMappings: MusicBrainz unreachable, creating new artist with provided MusicBrainz ID mapping")
l.Warn().Msg("MusicBrainz unreachable, creating new artist with provided MusicBrainz ID mapping")
var imgid uuid.UUID var imgid uuid.UUID
imgUrl, err := images.GetArtistImage(ctx, images.ArtistImageOpts{ imgUrl, imgErr := images.GetArtistImage(ctx, images.ArtistImageOpts{
Aliases: []string{a.Artist}, Aliases: []string{a.Artist},
}) })
if err == nil { if imgErr == nil && imgUrl != "" {
imgid = uuid.New() imgid = uuid.New()
err = DownloadAndCacheImage(ctx, imgid, imgUrl, ImageSourceSize()) if !opts.SkipCacheImage {
var size ImageSize
if cfg.FullImageCacheEnabled() {
size = ImageSizeFull
} else {
size = ImageSizeLarge
}
l.Debug().Msg("Downloading artist image from source...")
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
if err != nil { if err != nil {
l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to download artist image for artist '%s'", a.Artist) l.Err(err).Msg("Failed to cache image")
imgid = uuid.Nil }
} }
} else { } else {
l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to get artist image for artist '%s'", a.Artist) l.Err(imgErr).Msgf("matchArtistsByMBIDMappings: Failed to get artist image for artist '%s'", a.Artist)
} }
artist, err = d.SaveArtist(ctx, db.SaveArtistOpts{Name: a.Artist, MusicBrainzID: a.Mbid, Image: imgid, ImageSrc: imgUrl})
artist, err = d.SaveArtist(ctx, db.SaveArtistOpts{
Name: a.Artist,
MusicBrainzID: a.Mbid,
Image: imgid,
ImageSrc: imgUrl,
})
if err != nil { if err != nil {
l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to create artist '%s' in database", a.Artist) l.Err(err).Msgf("matchArtistsByMBIDMappings: Failed to create artist '%s' in database", a.Artist)
return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err) return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err)
} }
} }
result = append(result, artist) result = append(result, artist)
} }
return result, nil return result, nil
} }
@ -150,7 +165,7 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts,
} }
if id == uuid.Nil { if id == uuid.Nil {
l.Warn().Msg("Provided artist has uuid.Nil MusicBrainzID") l.Warn().Msg("Provided artist has uuid.Nil MusicBrainzID")
return matchArtistsByNames(ctx, opts.ArtistNames, result, d) return matchArtistsByNames(ctx, opts.ArtistNames, result, d, opts)
} }
a, err := d.GetArtist(ctx, db.GetArtistOpts{ a, err := d.GetArtist(ctx, db.GetArtistOpts{
MusicBrainzID: id, MusicBrainzID: id,
@ -160,7 +175,6 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts,
result = append(result, a) result = append(result, a)
continue continue
} }
if !errors.Is(err, pgx.ErrNoRows) { if !errors.Is(err, pgx.ErrNoRows) {
return nil, err return nil, err
} }
@ -168,22 +182,25 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts,
if len(opts.ArtistNames) < 1 { if len(opts.ArtistNames) < 1 {
opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle)) opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
} }
a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts.Mbzc)
a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts)
if err != nil { if err != nil {
l.Warn().Msg("MusicBrainz unreachable, falling back to artist name matching") l.Warn().Msg("MusicBrainz unreachable, falling back to artist name matching")
return matchArtistsByNames(ctx, opts.ArtistNames, result, d) return matchArtistsByNames(ctx, opts.ArtistNames, result, d, opts)
// return nil, err
} }
result = append(result, a) result = append(result, a)
} }
return result, nil return result, nil
} }
func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []string, d db.DB, mbz mbz.MusicBrainzCaller) (*models.Artist, error) {
func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []string, d db.DB, opts AssociateArtistsOpts) (*models.Artist, error) {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
aliases, err := mbz.GetArtistPrimaryAliases(ctx, mbzID) aliases, err := opts.Mbzc.GetArtistPrimaryAliases(ctx, mbzID)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("resolveAliasOrCreateArtist: %w", err)
} }
l.Debug().Msgf("Got aliases %v from MusicBrainz", aliases) l.Debug().Msgf("Got aliases %v from MusicBrainz", aliases)
@ -195,10 +212,10 @@ func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []st
a.MbzID = &mbzID a.MbzID = &mbzID
l.Debug().Msgf("Alias '%s' found in DB. Associating with MusicBrainz ID...", alias) l.Debug().Msgf("Alias '%s' found in DB. Associating with MusicBrainz ID...", alias)
if updateErr := d.UpdateArtist(ctx, db.UpdateArtistOpts{ID: a.ID, MusicBrainzID: mbzID}); updateErr != nil { if updateErr := d.UpdateArtist(ctx, db.UpdateArtistOpts{ID: a.ID, MusicBrainzID: mbzID}); updateErr != nil {
return nil, updateErr return nil, fmt.Errorf("resolveAliasOrCreateArtist: %w", updateErr)
} }
if saveAliasErr := d.SaveArtistAliases(ctx, a.ID, aliases, "MusicBrainz"); saveAliasErr != nil { if saveAliasErr := d.SaveArtistAliases(ctx, a.ID, aliases, "MusicBrainz"); saveAliasErr != nil {
return nil, saveAliasErr return nil, fmt.Errorf("resolveAliasOrCreateArtist: %w", saveAliasErr)
} }
return a, nil return a, nil
} }
@ -220,20 +237,22 @@ func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []st
Aliases: aliases, Aliases: aliases,
}) })
if err == nil && imgUrl != "" { if err == nil && imgUrl != "" {
imgid = uuid.New()
if !opts.SkipCacheImage {
var size ImageSize var size ImageSize
if cfg.FullImageCacheEnabled() { if cfg.FullImageCacheEnabled() {
size = ImageSizeFull size = ImageSizeFull
} else { } else {
size = ImageSizeLarge size = ImageSizeLarge
} }
imgid = uuid.New()
l.Debug().Msg("Downloading artist image from source...") l.Debug().Msg("Downloading artist image from source...")
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size) err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to cache image") l.Err(err).Msg("Failed to cache image")
} }
}
} else if err != nil { } else if err != nil {
l.Warn().Msgf("Failed to get artist image from ImageSrc: %s", err.Error()) l.Warn().AnErr("error", err).Msg("Failed to get artist image from ImageSrc")
} }
u, err := d.SaveArtist(ctx, db.SaveArtistOpts{ u, err := d.SaveArtist(ctx, db.SaveArtistOpts{
@ -244,13 +263,13 @@ func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []st
ImageSrc: imgUrl, ImageSrc: imgUrl,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("resolveAliasOrCreateArtist: %w", err)
} }
l.Info().Msgf("Created artist '%s' with MusicBrainz Artist ID", canonical) l.Info().Msgf("Created artist '%s' with MusicBrainz Artist ID", canonical)
return u, nil return u, nil
} }
func matchArtistsByNames(ctx context.Context, names []string, existing []*models.Artist, d db.DB) ([]*models.Artist, error) { func matchArtistsByNames(ctx context.Context, names []string, existing []*models.Artist, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
var result []*models.Artist var result []*models.Artist
@ -273,29 +292,31 @@ func matchArtistsByNames(ctx context.Context, names []string, existing []*models
Aliases: []string{name}, Aliases: []string{name},
}) })
if err == nil && imgUrl != "" { if err == nil && imgUrl != "" {
imgid = uuid.New()
if !opts.SkipCacheImage {
var size ImageSize var size ImageSize
if cfg.FullImageCacheEnabled() { if cfg.FullImageCacheEnabled() {
size = ImageSizeFull size = ImageSizeFull
} else { } else {
size = ImageSizeLarge size = ImageSizeLarge
} }
imgid = uuid.New()
l.Debug().Msg("Downloading artist image from source...") l.Debug().Msg("Downloading artist image from source...")
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size) err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to cache image") l.Err(err).Msg("Failed to cache image")
} }
}
} else if err != nil { } else if err != nil {
l.Debug().Msgf("Failed to get artist images for %s: %s", name, err.Error()) l.Debug().AnErr("error", err).Msgf("Failed to get artist images for %s", name)
} }
a, err = d.SaveArtist(ctx, db.SaveArtistOpts{Name: name, Image: imgid, ImageSrc: imgUrl}) a, err = d.SaveArtist(ctx, db.SaveArtistOpts{Name: name, Image: imgid, ImageSrc: imgUrl})
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("matchArtistsByNames: %w", err)
} }
l.Info().Msgf("Created artist '%s' with artist name", name) l.Info().Msgf("Created artist '%s' with artist name", name)
result = append(result, a) result = append(result, a)
} else { } else {
return nil, err return nil, fmt.Errorf("matchArtistsByNames: %w", err)
} }
} }
return result, nil return result, nil

@ -3,6 +3,7 @@ package catalog
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/logger"
@ -24,13 +25,13 @@ type AssociateTrackOpts struct {
func AssociateTrack(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*models.Track, error) { func AssociateTrack(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*models.Track, error) {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
if opts.TrackName == "" { if opts.TrackName == "" {
return nil, errors.New("missing required parameter 'opts.TrackName'") return nil, errors.New("AssociateTrack: missing required parameter 'opts.TrackName'")
} }
if len(opts.ArtistIDs) < 1 { if len(opts.ArtistIDs) < 1 {
return nil, errors.New("at least one artist id must be specified") return nil, errors.New("AssociateTrack: at least one artist id must be specified")
} }
if opts.AlbumID == 0 { if opts.AlbumID == 0 {
return nil, errors.New("release group id must be specified") return nil, errors.New("AssociateTrack: release group id must be specified")
} }
// first, try to match track Mbz ID // first, try to match track Mbz ID
if opts.TrackMbzID != uuid.Nil { if opts.TrackMbzID != uuid.Nil {
@ -52,12 +53,12 @@ func matchTrackByMbzID(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*
l.Debug().Msgf("Found track '%s' by MusicBrainz ID", track.Title) l.Debug().Msgf("Found track '%s' by MusicBrainz ID", track.Title)
return track, nil return track, nil
} else if !errors.Is(err, pgx.ErrNoRows) { } else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err return nil, fmt.Errorf("matchTrackByMbzID: %w", err)
} else { } else {
l.Debug().Msgf("Track '%s' could not be found by MusicBrainz ID", opts.TrackName) l.Debug().Msgf("Track '%s' could not be found by MusicBrainz ID", opts.TrackName)
track, err := matchTrackByTitleAndArtist(ctx, d, opts) track, err := matchTrackByTitleAndArtist(ctx, d, opts)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("matchTrackByMbzID: %w", err)
} }
l.Debug().Msgf("Updating track '%s' with MusicBrainz ID %s", opts.TrackName, opts.TrackMbzID) l.Debug().Msgf("Updating track '%s' with MusicBrainz ID %s", opts.TrackName, opts.TrackMbzID)
err = d.UpdateTrack(ctx, db.UpdateTrackOpts{ err = d.UpdateTrack(ctx, db.UpdateTrackOpts{
@ -65,7 +66,7 @@ func matchTrackByMbzID(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*
MusicBrainzID: opts.TrackMbzID, MusicBrainzID: opts.TrackMbzID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("matchTrackByMbzID: %w", err)
} }
track.MbzID = &opts.TrackMbzID track.MbzID = &opts.TrackMbzID
return track, nil return track, nil
@ -83,7 +84,7 @@ func matchTrackByTitleAndArtist(ctx context.Context, d db.DB, opts AssociateTrac
l.Debug().Msgf("Track '%s' found by title and artist match", track.Title) l.Debug().Msgf("Track '%s' found by title and artist match", track.Title)
return track, nil return track, nil
} else if !errors.Is(err, pgx.ErrNoRows) { } else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err return nil, fmt.Errorf("matchTrackByTitleAndArtist: %w", err)
} else { } else {
if opts.TrackMbzID != uuid.Nil { if opts.TrackMbzID != uuid.Nil {
mbzTrack, err := opts.Mbzc.GetTrack(ctx, opts.TrackMbzID) mbzTrack, err := opts.Mbzc.GetTrack(ctx, opts.TrackMbzID)
@ -107,7 +108,7 @@ func matchTrackByTitleAndArtist(ctx context.Context, d db.DB, opts AssociateTrac
Duration: opts.Duration, Duration: opts.Duration,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("matchTrackByTitleAndArtist: %w", err)
} }
if opts.TrackMbzID == uuid.Nil { if opts.TrackMbzID == uuid.Nil {
l.Info().Msgf("Created track '%s' with title and artist", opts.TrackName) l.Info().Msgf("Created track '%s' with title and artist", opts.TrackName)

@ -6,6 +6,7 @@ package catalog
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@ -39,6 +40,9 @@ type SubmitListenOpts struct {
// artist, release, release group, and track in DB // artist, release, release group, and track in DB
SkipSaveListen bool SkipSaveListen bool
// When true, skips caching the images and only stores the image url in the db
SkipCacheImage bool
MbzCaller mbz.MusicBrainzCaller MbzCaller mbz.MusicBrainzCaller
ArtistNames []string ArtistNames []string
Artist string Artist string
@ -51,6 +55,7 @@ type SubmitListenOpts struct {
ReleaseMbzID uuid.UUID ReleaseMbzID uuid.UUID
ReleaseGroupMbzID uuid.UUID ReleaseGroupMbzID uuid.UUID
Time time.Time Time time.Time
UserID int32 UserID int32
Client string Client string
} }
@ -76,10 +81,11 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
ArtistMbidMap: opts.ArtistMbidMappings, ArtistMbidMap: opts.ArtistMbidMappings,
Mbzc: opts.MbzCaller, Mbzc: opts.MbzCaller,
TrackTitle: opts.TrackTitle, TrackTitle: opts.TrackTitle,
SkipCacheImage: opts.SkipCacheImage,
}) })
if err != nil { if err != nil {
l.Error().Err(err).Msg("Failed to associate artists to listen") l.Err(err).Msg("Failed to associate artists to listen")
return err return fmt.Errorf("SubmitListen: %w", err)
} else if len(artists) < 1 { } else if len(artists) < 1 {
l.Debug().Msg("Failed to associate any artists to release") l.Debug().Msg("Failed to associate any artists to release")
} }
@ -97,10 +103,11 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
TrackName: opts.TrackTitle, TrackName: opts.TrackTitle,
Mbzc: opts.MbzCaller, Mbzc: opts.MbzCaller,
Artists: artists, Artists: artists,
SkipCacheImage: opts.SkipCacheImage,
}) })
if err != nil { if err != nil {
l.Error().Err(err).Msg("Failed to associate release group to listen") l.Error().Err(err).Msg("Failed to associate release group to listen")
return err return fmt.Errorf("SubmitListen: %w", err)
} }
l.Debug().Any("album", rg).Msg("Matched listen to release") l.Debug().Any("album", rg).Msg("Matched listen to release")
@ -120,7 +127,7 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
}) })
if err != nil { if err != nil {
l.Error().Err(err).Msg("Failed to associate track to listen") l.Error().Err(err).Msg("Failed to associate track to listen")
return err return fmt.Errorf("SubmitListen: %w", err)
} }
l.Debug().Any("track", track).Msg("Matched listen to track") l.Debug().Any("track", track).Msg("Matched listen to track")

@ -82,17 +82,17 @@ func SourceImageDir() string {
func ValidateImageURL(url string) error { func ValidateImageURL(url string) error {
resp, err := http.Head(url) resp, err := http.Head(url)
if err != nil { if err != nil {
return fmt.Errorf("failed to perform HEAD request: %w", err) return fmt.Errorf("ValidateImageURL: http.Head: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HEAD request failed, status code: %d", resp.StatusCode) return fmt.Errorf("ValidateImageURL: HEAD request failed, status code: %d", resp.StatusCode)
} }
contentType := resp.Header.Get("Content-Type") contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") { if !strings.HasPrefix(contentType, "image/") {
return fmt.Errorf("URL does not point to an image, content type: %s", contentType) return fmt.Errorf("ValidateImageURL: URL does not point to an image, content type: %s", contentType)
} }
return nil return nil
@ -103,20 +103,24 @@ func DownloadAndCacheImage(ctx context.Context, id uuid.UUID, url string, size I
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
err := ValidateImageURL(url) err := ValidateImageURL(url)
if err != nil { if err != nil {
return err return fmt.Errorf("DownloadAndCacheImage: %w", err)
} }
l.Debug().Msgf("Downloading image for ID %s", id) l.Debug().Msgf("Downloading image for ID %s", id)
resp, err := http.Get(url) resp, err := http.Get(url)
if err != nil { if err != nil {
return fmt.Errorf("failed to download image: %w", err) return fmt.Errorf("DownloadAndCacheImage: http.Get: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download image, status code: %d", resp.StatusCode) return fmt.Errorf("DownloadAndCacheImage: failed to download image, status: %s", resp.Status)
} }
return CompressAndSaveImage(ctx, id.String(), size, resp.Body) err = CompressAndSaveImage(ctx, id.String(), size, resp.Body)
if err != nil {
return fmt.Errorf("DownloadAndCacheImage: %w", err)
}
return nil
} }
// Compresses an image to the specified size, then saves it to the correct cache folder. // Compresses an image to the specified size, then saves it to the correct cache folder.
@ -124,16 +128,24 @@ func CompressAndSaveImage(ctx context.Context, filename string, size ImageSize,
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
if size == ImageSizeFull { if size == ImageSizeFull {
return saveImage(filename, size, body) err := saveImage(filename, size, body)
if err != nil {
return fmt.Errorf("CompressAndSaveImage: %w", err)
}
return nil
} }
l.Debug().Msg("Creating resized image") l.Debug().Msg("Creating resized image")
compressed, err := compressImage(size, body) compressed, err := compressImage(size, body)
if err != nil { if err != nil {
return err return fmt.Errorf("CompressAndSaveImage: %w", err)
} }
return saveImage(filename, size, compressed) err = saveImage(filename, size, compressed)
if err != nil {
return fmt.Errorf("CompressAndSaveImage: %w", err)
}
return nil
} }
// SaveImage saves an image to the image_cache/{size} folder // SaveImage saves an image to the image_cache/{size} folder
@ -144,21 +156,21 @@ func saveImage(filename string, size ImageSize, data io.Reader) error {
// Ensure the cache directory exists // Ensure the cache directory exists
err := os.MkdirAll(filepath.Join(cacheDir, string(size)), 0744) err := os.MkdirAll(filepath.Join(cacheDir, string(size)), 0744)
if err != nil { if err != nil {
return fmt.Errorf("failed to create full image cache directory: %w", err) return fmt.Errorf("saveImage: failed to create full image cache directory: %w", err)
} }
// Create a file in the cache directory // Create a file in the cache directory
imagePath := filepath.Join(cacheDir, string(size), filename) imagePath := filepath.Join(cacheDir, string(size), filename)
file, err := os.Create(imagePath) file, err := os.Create(imagePath)
if err != nil { if err != nil {
return fmt.Errorf("failed to create image file: %w", err) return fmt.Errorf("saveImage: failed to create image file: %w", err)
} }
defer file.Close() defer file.Close()
// Save the image to the file // Save the image to the file
_, err = io.Copy(file, data) _, err = io.Copy(file, data)
if err != nil { if err != nil {
return fmt.Errorf("failed to save image: %w", err) return fmt.Errorf("saveImage: failed to save image: %w", err)
} }
return nil return nil
@ -167,7 +179,7 @@ func saveImage(filename string, size ImageSize, data io.Reader) error {
func compressImage(size ImageSize, data io.Reader) (io.Reader, error) { func compressImage(size ImageSize, data io.Reader) (io.Reader, error) {
imgBytes, err := io.ReadAll(data) imgBytes, err := io.ReadAll(data)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("compressImage: io.ReadAll: %w", err)
} }
px := GetImageSize(size) px := GetImageSize(size)
// Resize with bimg // Resize with bimg
@ -180,10 +192,10 @@ func compressImage(size ImageSize, data io.Reader) (io.Reader, error) {
Type: bimg.WEBP, Type: bimg.WEBP,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("compressImage: bimg.NewImage: %w", err)
} }
if len(imgBytes) == 0 { if len(imgBytes) == 0 {
return nil, fmt.Errorf("compression failed") return nil, fmt.Errorf("compressImage: failed to compress image: %w", err)
} }
return bytes.NewReader(imgBytes), nil return bytes.NewReader(imgBytes), nil
} }
@ -198,19 +210,19 @@ func DeleteImage(filename uuid.UUID) error {
// } // }
err := os.Remove(path.Join(cacheDir, "full", filename.String())) err := os.Remove(path.Join(cacheDir, "full", filename.String()))
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
return err return fmt.Errorf("DeleteImage: %w", err)
} }
err = os.Remove(path.Join(cacheDir, "large", filename.String())) err = os.Remove(path.Join(cacheDir, "large", filename.String()))
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
return err return fmt.Errorf("DeleteImage: %w", err)
} }
err = os.Remove(path.Join(cacheDir, "medium", filename.String())) err = os.Remove(path.Join(cacheDir, "medium", filename.String()))
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
return err return fmt.Errorf("DeleteImage: %w", err)
} }
err = os.Remove(path.Join(cacheDir, "small", filename.String())) err = os.Remove(path.Join(cacheDir, "small", filename.String()))
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
return err return fmt.Errorf("DeleteImage: %w", err)
} }
return nil return nil
} }
@ -230,7 +242,7 @@ func PruneOrphanedImages(ctx context.Context, store db.DB) error {
for _, dir := range []string{"large", "medium", "small", "full"} { for _, dir := range []string{"large", "medium", "small", "full"} {
c, err := pruneDirImgs(ctx, store, path.Join(cacheDir, dir), memo) c, err := pruneDirImgs(ctx, store, path.Join(cacheDir, dir), memo)
if err != nil { if err != nil {
return err return fmt.Errorf("PruneOrphanedImages: %w", err)
} }
count += c count += c
} }
@ -256,7 +268,7 @@ func pruneDirImgs(ctx context.Context, store db.DB, path string, memo map[string
} }
exists, err := store.ImageHasAssociation(ctx, imageid) exists, err := store.ImageHasAssociation(ctx, imageid)
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("pruneDirImages: %w", err)
} else if exists { } else if exists {
continue continue
} }

@ -41,6 +41,7 @@ const (
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"
IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX" IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX"
FETCH_IMAGES_DURING_IMPORT_ENV = "KOITO_FETCH_IMAGES_DURING_IMPORT"
) )
type config struct { type config struct {
@ -63,6 +64,7 @@ type config struct {
disableCAA bool disableCAA bool
disableMusicBrainz bool disableMusicBrainz bool
skipImport bool skipImport bool
fetchImageDuringImport bool
allowedHosts []string allowedHosts []string
allowAllHosts bool allowAllHosts bool
allowedOrigins []string allowedOrigins []string
@ -85,7 +87,10 @@ func Load(getenv func(string) string, version string) error {
once.Do(func() { once.Do(func() {
globalConfig, err = loadConfig(getenv, version) globalConfig, err = loadConfig(getenv, version)
}) })
return err if err != nil {
return fmt.Errorf("cfg.Load: %w", err)
}
return nil
} }
// loadConfig loads the configuration from environment variables. // loadConfig loads the configuration from environment variables.
@ -94,7 +99,7 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
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("loadConfig: required parameter " + DATABASE_URL_ENV + " not provided")
} }
cfg.bindAddr = getenv(BIND_ADDR_ENV) cfg.bindAddr = getenv(BIND_ADDR_ENV)
var err error var err error
@ -136,6 +141,7 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
cfg.disableRateLimit = parseBool(getenv(DISABLE_RATE_LIMIT_ENV)) cfg.disableRateLimit = parseBool(getenv(DISABLE_RATE_LIMIT_ENV))
cfg.structuredLogging = parseBool(getenv(ENABLE_STRUCTURED_LOGGING_ENV)) cfg.structuredLogging = parseBool(getenv(ENABLE_STRUCTURED_LOGGING_ENV))
cfg.fetchImageDuringImport = parseBool(getenv(FETCH_IMAGES_DURING_IMPORT_ENV))
cfg.enableFullImageCache = parseBool(getenv(ENABLE_FULL_IMAGE_CACHE_ENV)) cfg.enableFullImageCache = parseBool(getenv(ENABLE_FULL_IMAGE_CACHE_ENV))
cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV)) cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV))
@ -211,12 +217,6 @@ func ConfigDir() string {
return globalConfig.configDir return globalConfig.configDir
} }
// func BaseUrl() string {
// lock.RLock()
// defer lock.RUnlock()
// return globalConfig.baseUrl
// }
func DatabaseUrl() string { func DatabaseUrl() string {
lock.RLock() lock.RLock()
defer lock.RUnlock() defer lock.RUnlock()
@ -339,5 +339,13 @@ func ThrottleImportMs() int {
// returns the before, after times, in that order // returns the before, after times, in that order
func ImportWindow() (time.Time, time.Time) { func ImportWindow() (time.Time, time.Time) {
lock.RLock()
defer lock.RUnlock()
return globalConfig.importBefore, globalConfig.importAfter return globalConfig.importBefore, globalConfig.importAfter
} }
func FetchImagesDuringImport() bool {
lock.RLock()
defer lock.RUnlock()
return globalConfig.fetchImageDuringImport
}

@ -14,6 +14,8 @@ type DB interface {
GetArtist(ctx context.Context, opts GetArtistOpts) (*models.Artist, error) GetArtist(ctx context.Context, opts GetArtistOpts) (*models.Artist, error)
GetAlbum(ctx context.Context, opts GetAlbumOpts) (*models.Album, error) GetAlbum(ctx context.Context, opts GetAlbumOpts) (*models.Album, error)
GetTrack(ctx context.Context, opts GetTrackOpts) (*models.Track, error) GetTrack(ctx context.Context, opts GetTrackOpts) (*models.Track, error)
GetArtistsForAlbum(ctx context.Context, id int32) ([]*models.Artist, error)
GetArtistsForTrack(ctx context.Context, id int32) ([]*models.Artist, error)
GetTopTracksPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Track], error) GetTopTracksPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Track], error)
GetTopArtistsPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Artist], error) GetTopArtistsPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Artist], error)
GetTopAlbumsPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Album], error) GetTopAlbumsPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Album], error)
@ -48,6 +50,8 @@ type DB interface {
SetPrimaryArtistAlias(ctx context.Context, id int32, alias string) error SetPrimaryArtistAlias(ctx context.Context, id int32, alias string) error
SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string) error SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string) error
SetPrimaryTrackAlias(ctx context.Context, id int32, alias string) error SetPrimaryTrackAlias(ctx context.Context, id int32, alias string) error
SetPrimaryAlbumArtist(ctx context.Context, id int32, artistId int32, value bool) error
SetPrimaryTrackArtist(ctx context.Context, id int32, artistId int32, value bool) error
// Delete // Delete
DeleteArtist(ctx context.Context, id int32) error DeleteArtist(ctx context.Context, id int32) error
DeleteAlbum(ctx context.Context, id int32) error DeleteAlbum(ctx context.Context, id int32) error

@ -3,6 +3,7 @@ package psql
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strings" "strings"
"time" "time"
@ -41,11 +42,11 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
Column1: opts.Titles, Column1: opts.Titles,
}) })
} else { } else {
return nil, errors.New("insufficient information to get album") return nil, errors.New("GetAlbum: insufficient information to get album")
} }
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetAlbum: %w", err)
} }
count, err := d.q.CountListensFromRelease(ctx, repository.CountListensFromReleaseParams{ count, err := d.q.CountListensFromRelease(ctx, repository.CountListensFromReleaseParams{
@ -54,7 +55,7 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
ReleaseID: row.ID, ReleaseID: row.ID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetAlbum: CountListensFromRelease: %w", err)
} }
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
@ -62,7 +63,7 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
AlbumID: row.ID, AlbumID: row.ID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err)
} }
return &models.Album{ return &models.Album{
@ -87,17 +88,17 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al
insertImage = &opts.Image insertImage = &opts.Image
} }
if len(opts.ArtistIDs) < 1 { if len(opts.ArtistIDs) < 1 {
return nil, errors.New("required parameter 'ArtistIDs' missing") return nil, errors.New("SaveAlbum: required parameter 'ArtistIDs' missing")
} }
for _, aid := range opts.ArtistIDs { for _, aid := range opts.ArtistIDs {
if aid == 0 { if aid == 0 {
return nil, errors.New("none of 'ArtistIDs' may be 0") return nil, errors.New("SaveAlbum: none of 'ArtistIDs' may be 0")
} }
} }
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return nil, err return nil, fmt.Errorf("SaveAlbum: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
@ -109,7 +110,7 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al
ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""}, ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""},
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SaveAlbum: InsertRelease: %w", err)
} }
for _, artistId := range opts.ArtistIDs { for _, artistId := range opts.ArtistIDs {
l.Debug().Msgf("Associating release '%s' to artist with ID %d", opts.Title, artistId) l.Debug().Msgf("Associating release '%s' to artist with ID %d", opts.Title, artistId)
@ -118,7 +119,7 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al
ReleaseID: r.ID, ReleaseID: r.ID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SaveAlbum: AssociateArtistToRelease: %w", err)
} }
} }
l.Debug().Msgf("Saving canonical alias %s for release %d", opts.Title, r.ID) l.Debug().Msgf("Saving canonical alias %s for release %d", opts.Title, r.ID)
@ -130,11 +131,12 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al
}) })
if err != nil { if err != nil {
l.Err(err).Msgf("Failed to save canonical alias for album %d", r.ID) l.Err(err).Msgf("Failed to save canonical alias for album %d", r.ID)
return nil, fmt.Errorf("SaveAlbum: InsertReleaseAlias: %w", err)
} }
err = tx.Commit(ctx) err = tx.Commit(ctx)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SaveAlbum: Commit: %w", err)
} }
return &models.Album{ return &models.Album{
@ -151,7 +153,7 @@ func (d *Psql) AddArtistsToAlbum(ctx context.Context, opts db.AddArtistsToAlbumO
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("AddArtistsToAlbum: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
@ -162,6 +164,7 @@ func (d *Psql) AddArtistsToAlbum(ctx context.Context, opts db.AddArtistsToAlbumO
}) })
if err != nil { if err != nil {
l.Error().Err(err).Msgf("Failed to associate release %d with artist %d", opts.AlbumID, id) l.Error().Err(err).Msgf("Failed to associate release %d with artist %d", opts.AlbumID, id)
return fmt.Errorf("AddArtistsToAlbum: AssociateArtistToRelease: %w", err)
} }
} }
return tx.Commit(ctx) return tx.Commit(ctx)
@ -175,7 +178,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error {
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("UpdateAlbum: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
@ -186,7 +189,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error {
MusicBrainzID: &opts.MusicBrainzID, MusicBrainzID: &opts.MusicBrainzID,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("UpdateAlbum: UpdateReleaseMbzID: %w", err)
} }
} }
if opts.Image != uuid.Nil { if opts.Image != uuid.Nil {
@ -197,7 +200,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error {
ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""}, ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""},
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("UpdateAlbum: UpdateReleaseImage: %w", err)
} }
} }
if opts.VariousArtistsUpdate { if opts.VariousArtistsUpdate {
@ -207,7 +210,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error {
VariousArtists: opts.VariousArtistsValue, VariousArtists: opts.VariousArtistsValue,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("UpdateAlbum: UpdateReleaseVariousArtists: %w", err)
} }
} }
return tx.Commit(ctx) return tx.Commit(ctx)
@ -221,13 +224,13 @@ func (d *Psql) SaveAlbumAliases(ctx context.Context, id int32, aliases []string,
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("SaveAlbumAliases: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
existing, err := qtx.GetAllReleaseAliases(ctx, id) existing, err := qtx.GetAllReleaseAliases(ctx, id)
if err != nil { if err != nil {
return err return fmt.Errorf("SaveAlbumAliases: GetAllReleaseAliases: %w", err)
} }
for _, v := range existing { for _, v := range existing {
aliases = append(aliases, v.Alias) aliases = append(aliases, v.Alias)
@ -235,7 +238,7 @@ func (d *Psql) SaveAlbumAliases(ctx context.Context, id int32, aliases []string,
utils.Unique(&aliases) utils.Unique(&aliases)
for _, alias := range aliases { for _, alias := range aliases {
if strings.TrimSpace(alias) == "" { if strings.TrimSpace(alias) == "" {
return errors.New("aliases cannot be blank") return errors.New("SaveAlbumAliases: aliases cannot be blank")
} }
err = qtx.InsertReleaseAlias(ctx, repository.InsertReleaseAliasParams{ err = qtx.InsertReleaseAlias(ctx, repository.InsertReleaseAliasParams{
Alias: strings.TrimSpace(alias), Alias: strings.TrimSpace(alias),
@ -244,7 +247,7 @@ func (d *Psql) SaveAlbumAliases(ctx context.Context, id int32, aliases []string,
IsPrimary: false, IsPrimary: false,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("SaveAlbumAliases: InsertReleaseAlias: %w", err)
} }
} }
return tx.Commit(ctx) return tx.Commit(ctx)
@ -263,7 +266,7 @@ func (d *Psql) DeleteAlbumAlias(ctx context.Context, id int32, alias string) err
func (d *Psql) GetAllAlbumAliases(ctx context.Context, id int32) ([]models.Alias, error) { func (d *Psql) GetAllAlbumAliases(ctx context.Context, id int32) ([]models.Alias, error) {
rows, err := d.q.GetAllReleaseAliases(ctx, id) rows, err := d.q.GetAllReleaseAliases(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetAllAlbumAliases: GetAllReleaseAliases: %w", err)
} }
aliases := make([]models.Alias, len(rows)) aliases := make([]models.Alias, len(rows))
for i, row := range rows { for i, row := range rows {
@ -285,14 +288,14 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string)
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("SetPrimaryAlbumAlias: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
// get all aliases // get all aliases
aliases, err := qtx.GetAllReleaseAliases(ctx, id) aliases, err := qtx.GetAllReleaseAliases(ctx, id)
if err != nil { if err != nil {
return err return fmt.Errorf("SetPrimaryAlbumAlias: GetAllReleaseAliases: %w", err)
} }
primary := "" primary := ""
exists := false exists := false
@ -309,7 +312,7 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string)
return nil return nil
} }
if !exists { if !exists {
return errors.New("alias does not exist") return errors.New("SetPrimaryAlbumAlias: alias does not exist")
} }
err = qtx.SetReleaseAliasPrimaryStatus(ctx, repository.SetReleaseAliasPrimaryStatusParams{ err = qtx.SetReleaseAliasPrimaryStatus(ctx, repository.SetReleaseAliasPrimaryStatusParams{
ReleaseID: id, ReleaseID: id,
@ -317,7 +320,7 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string)
IsPrimary: true, IsPrimary: true,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("SetPrimaryAlbumAlias: SetReleaseAliasPrimaryStatus: %w", err)
} }
err = qtx.SetReleaseAliasPrimaryStatus(ctx, repository.SetReleaseAliasPrimaryStatusParams{ err = qtx.SetReleaseAliasPrimaryStatus(ctx, repository.SetReleaseAliasPrimaryStatusParams{
ReleaseID: id, ReleaseID: id,
@ -325,7 +328,61 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string)
IsPrimary: false, IsPrimary: false,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("SetPrimaryAlbumAlias: SetReleaseAliasPrimaryStatus: %w", err)
}
return tx.Commit(ctx)
}
func (d *Psql) SetPrimaryAlbumArtist(ctx context.Context, id int32, artistId int32, value bool) error {
l := logger.FromContext(ctx)
if id == 0 {
return errors.New("artist id not specified")
}
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return fmt.Errorf("SetPrimaryAlbumArtist: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
// get all artists
artists, err := qtx.GetReleaseArtists(ctx, id)
if err != nil {
return fmt.Errorf("SetPrimaryAlbumArtist: GetReleaseArtists: %w", err)
}
var primary int32
for _, v := range artists {
// i dont get it??? is_primary is not a nullable column??? why use pgtype.Bool???
// why not just use boolean??? is sqlc stupid??? am i stupid???????
if v.IsPrimary.Valid && v.IsPrimary.Bool {
primary = v.ID
}
}
if value && primary == artistId {
// no-op
return nil
}
l.Debug().Msgf("Marking artist with id %d as 'primary = %v' on album with id %d", artistId, value, id)
err = qtx.UpdateReleasePrimaryArtist(ctx, repository.UpdateReleasePrimaryArtistParams{
ReleaseID: id,
ArtistID: artistId,
IsPrimary: value,
})
if err != nil {
return fmt.Errorf("SetPrimaryAlbumArtist: UpdateReleasePrimaryArtist: %w", err)
}
if value && primary != 0 {
// if we were marking a new one as primary and there was already one marked as primary,
// unmark that one as there can only be one
l.Debug().Msgf("Unmarking artist with id %d as primary on album with id %d", primary, id)
err = qtx.UpdateReleasePrimaryArtist(ctx, repository.UpdateReleasePrimaryArtistParams{
ReleaseID: id,
ArtistID: primary,
IsPrimary: false,
})
if err != nil {
return fmt.Errorf("SetPrimaryAlbumArtist: UpdateReleasePrimaryArtist: %w", err)
}
} }
return tx.Commit(ctx) return tx.Commit(ctx)
} }

@ -3,6 +3,7 @@ package psql
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strings" "strings"
"time" "time"
@ -23,7 +24,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
l.Debug().Msgf("Fetching artist from DB with id %d", opts.ID) l.Debug().Msgf("Fetching artist from DB with id %d", opts.ID)
row, err := d.q.GetArtist(ctx, opts.ID) row, err := d.q.GetArtist(ctx, opts.ID)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetArtist: GetArtist by ID: %w", err)
} }
count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{ count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{
ListenedAt: time.Unix(0, 0), ListenedAt: time.Unix(0, 0),
@ -31,14 +32,14 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
ArtistID: row.ID, ArtistID: row.ID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
} }
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime, Period: db.PeriodAllTime,
ArtistID: row.ID, ArtistID: row.ID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
} }
return &models.Artist{ return &models.Artist{
ID: row.ID, ID: row.ID,
@ -53,7 +54,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID) l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID)
row, err := d.q.GetArtistByMbzID(ctx, &opts.MusicBrainzID) row, err := d.q.GetArtistByMbzID(ctx, &opts.MusicBrainzID)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetArtist: GetArtistByMbzID: %w", err)
} }
count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{ count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{
ListenedAt: time.Unix(0, 0), ListenedAt: time.Unix(0, 0),
@ -61,14 +62,14 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
ArtistID: row.ID, ArtistID: row.ID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
} }
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime, Period: db.PeriodAllTime,
ArtistID: row.ID, ArtistID: row.ID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
} }
return &models.Artist{ return &models.Artist{
ID: row.ID, ID: row.ID,
@ -83,7 +84,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name) l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name)
row, err := d.q.GetArtistByName(ctx, opts.Name) row, err := d.q.GetArtistByName(ctx, opts.Name)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetArtist: GetArtistByName: %w", err)
} }
count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{ count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{
ListenedAt: time.Unix(0, 0), ListenedAt: time.Unix(0, 0),
@ -91,14 +92,14 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
ArtistID: row.ID, ArtistID: row.ID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
} }
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime, Period: db.PeriodAllTime,
ArtistID: row.ID, ArtistID: row.ID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
} }
return &models.Artist{ return &models.Artist{
ID: row.ID, ID: row.ID,
@ -118,35 +119,36 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
func (d *Psql) SaveArtistAliases(ctx context.Context, id int32, aliases []string, source string) error { func (d *Psql) SaveArtistAliases(ctx context.Context, id int32, aliases []string, source string) error {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
if id == 0 { if id == 0 {
return errors.New("artist id not specified") return errors.New("SaveArtistAliases: artist id not specified")
} }
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("SaveArtistAliases: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
existing, err := qtx.GetAllArtistAliases(ctx, id) existing, err := qtx.GetAllArtistAliases(ctx, id)
if err != nil { if err != nil {
return err return fmt.Errorf("SaveArtistAliases: GetAllArtistAliases: %w", err)
} }
for _, v := range existing { for _, v := range existing {
aliases = append(aliases, v.Alias) aliases = append(aliases, v.Alias)
} }
utils.Unique(&aliases) utils.Unique(&aliases)
for _, alias := range aliases { for _, alias := range aliases {
if strings.TrimSpace(alias) == "" { alias = strings.TrimSpace(alias)
return errors.New("aliases cannot be blank") if alias == "" {
return errors.New("SaveArtistAliases: aliases cannot be blank")
} }
err = qtx.InsertArtistAlias(ctx, repository.InsertArtistAliasParams{ err = qtx.InsertArtistAlias(ctx, repository.InsertArtistAliasParams{
Alias: strings.TrimSpace(alias), Alias: alias,
ArtistID: id, ArtistID: id,
Source: source, Source: source,
IsPrimary: false, IsPrimary: false,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("SaveArtistAliases: InsertArtistAlias: %w", err)
} }
} }
return tx.Commit(ctx) return tx.Commit(ctx)
@ -170,13 +172,13 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models.
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return nil, err return nil, fmt.Errorf("SaveArtist: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
opts.Name = strings.TrimSpace(opts.Name) opts.Name = strings.TrimSpace(opts.Name)
if opts.Name == "" { if opts.Name == "" {
return nil, errors.New("name must not be blank") return nil, errors.New("SaveArtist: name must not be blank")
} }
l.Debug().Msgf("Inserting artist '%s' into DB", opts.Name) l.Debug().Msgf("Inserting artist '%s' into DB", opts.Name)
a, err := qtx.InsertArtist(ctx, repository.InsertArtistParams{ a, err := qtx.InsertArtist(ctx, repository.InsertArtistParams{
@ -185,7 +187,7 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models.
ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""}, ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""},
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SaveArtist: InsertArtist: %w", err)
} }
l.Debug().Msgf("Inserting canonical alias '%s' into DB for artist with id %d", opts.Name, a.ID) l.Debug().Msgf("Inserting canonical alias '%s' into DB for artist with id %d", opts.Name, a.ID)
err = qtx.InsertArtistAlias(ctx, repository.InsertArtistAliasParams{ err = qtx.InsertArtistAlias(ctx, repository.InsertArtistAliasParams{
@ -195,13 +197,13 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models.
IsPrimary: true, IsPrimary: true,
}) })
if err != nil { if err != nil {
l.Error().Err(err).Msgf("Error inserting canonical alias for artist '%s'", opts.Name) l.Err(err).Msgf("SaveArtist: error inserting canonical alias for artist '%s'", opts.Name)
return nil, err return nil, fmt.Errorf("SaveArtist: InsertArtistAlias: %w", err)
} }
err = tx.Commit(ctx) err = tx.Commit(ctx)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to commit insert artist transaction") l.Err(err).Msg("Failed to commit insert artist transaction")
return nil, err return nil, fmt.Errorf("SaveArtist: Commit: %w", err)
} }
artist := &models.Artist{ artist := &models.Artist{
ID: a.ID, ID: a.ID,
@ -214,7 +216,7 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models.
l.Debug().Msgf("Inserting aliases '%v' into DB for artist '%s'", opts.Aliases, opts.Name) l.Debug().Msgf("Inserting aliases '%v' into DB for artist '%s'", opts.Aliases, opts.Name)
err = d.SaveArtistAliases(ctx, a.ID, opts.Aliases, "MusicBrainz") err = d.SaveArtistAliases(ctx, a.ID, opts.Aliases, "MusicBrainz")
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SaveArtist: SaveArtistAliases: %w", err)
} }
artist.Aliases = opts.Aliases artist.Aliases = opts.Aliases
} }
@ -224,12 +226,12 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models.
func (d *Psql) UpdateArtist(ctx context.Context, opts db.UpdateArtistOpts) error { func (d *Psql) UpdateArtist(ctx context.Context, opts db.UpdateArtistOpts) error {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
if opts.ID == 0 { if opts.ID == 0 {
return errors.New("artist id not specified") return errors.New("UpdateArtist: artist id not specified")
} }
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("UpdateArtist: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
@ -240,7 +242,7 @@ func (d *Psql) UpdateArtist(ctx context.Context, opts db.UpdateArtistOpts) error
MusicBrainzID: &opts.MusicBrainzID, MusicBrainzID: &opts.MusicBrainzID,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("UpdateArtist: UpdateArtistMbzID: %w", err)
} }
} }
if opts.Image != uuid.Nil { if opts.Image != uuid.Nil {
@ -251,10 +253,15 @@ func (d *Psql) UpdateArtist(ctx context.Context, opts db.UpdateArtistOpts) error
ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""}, ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""},
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("UpdateArtist: UpdateArtistImage: %w", err)
} }
} }
return tx.Commit(ctx) err = tx.Commit(ctx)
if err != nil {
l.Err(err).Msg("Failed to commit update artist transaction")
return fmt.Errorf("UpdateArtist: Commit: %w", err)
}
return nil
} }
func (d *Psql) DeleteArtistAlias(ctx context.Context, id int32, alias string) error { func (d *Psql) DeleteArtistAlias(ctx context.Context, id int32, alias string) error {
@ -263,10 +270,11 @@ func (d *Psql) DeleteArtistAlias(ctx context.Context, id int32, alias string) er
Alias: alias, Alias: alias,
}) })
} }
func (d *Psql) GetAllArtistAliases(ctx context.Context, id int32) ([]models.Alias, error) { func (d *Psql) GetAllArtistAliases(ctx context.Context, id int32) ([]models.Alias, error) {
rows, err := d.q.GetAllArtistAliases(ctx, id) rows, err := d.q.GetAllArtistAliases(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetAllArtistAliases: %w", err)
} }
aliases := make([]models.Alias, len(rows)) aliases := make([]models.Alias, len(rows))
for i, row := range rows { for i, row := range rows {
@ -283,19 +291,18 @@ func (d *Psql) GetAllArtistAliases(ctx context.Context, id int32) ([]models.Alia
func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string) error { func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string) error {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
if id == 0 { if id == 0 {
return errors.New("artist id not specified") return errors.New("SetPrimaryArtistAlias: artist id not specified")
} }
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("SetPrimaryArtistAlias: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
// get all aliases
aliases, err := qtx.GetAllArtistAliases(ctx, id) aliases, err := qtx.GetAllArtistAliases(ctx, id)
if err != nil { if err != nil {
return err return fmt.Errorf("SetPrimaryArtistAlias: GetAllArtistAliases: %w", err)
} }
primary := "" primary := ""
exists := false exists := false
@ -308,11 +315,10 @@ func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string
} }
} }
if primary == alias { if primary == alias {
// no-op rename
return nil return nil
} }
if !exists { if !exists {
return errors.New("alias does not exist") return errors.New("SetPrimaryArtistAlias: alias does not exist")
} }
err = qtx.SetArtistAliasPrimaryStatus(ctx, repository.SetArtistAliasPrimaryStatusParams{ err = qtx.SetArtistAliasPrimaryStatus(ctx, repository.SetArtistAliasPrimaryStatusParams{
ArtistID: id, ArtistID: id,
@ -320,7 +326,7 @@ func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string
IsPrimary: true, IsPrimary: true,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("SetPrimaryArtistAlias: SetArtistAliasPrimaryStatus (primary): %w", err)
} }
err = qtx.SetArtistAliasPrimaryStatus(ctx, repository.SetArtistAliasPrimaryStatusParams{ err = qtx.SetArtistAliasPrimaryStatus(ctx, repository.SetArtistAliasPrimaryStatusParams{
ArtistID: id, ArtistID: id,
@ -328,7 +334,57 @@ func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string
IsPrimary: false, IsPrimary: false,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("SetPrimaryArtistAlias: SetArtistAliasPrimaryStatus (previous primary): %w", err)
} }
return tx.Commit(ctx) err = tx.Commit(ctx)
if err != nil {
l.Err(err).Msg("Failed to commit transaction")
return fmt.Errorf("SetPrimaryArtistAlias: Commit: %w", err)
}
return nil
}
func (d *Psql) GetArtistsForAlbum(ctx context.Context, id int32) ([]*models.Artist, error) {
l := logger.FromContext(ctx)
l.Debug().Msgf("Fetching artists for album ID %d", id)
rows, err := d.q.GetReleaseArtists(ctx, id)
if err != nil {
return nil, fmt.Errorf("GetArtistsForAlbum: %w", err)
}
artists := make([]*models.Artist, len(rows))
for i, row := range rows {
artists[i] = &models.Artist{
ID: row.ID,
Name: row.Name,
MbzID: row.MusicBrainzID,
Image: row.Image,
IsPrimary: row.IsPrimary.Valid && row.IsPrimary.Bool,
}
}
return artists, nil
}
func (d *Psql) GetArtistsForTrack(ctx context.Context, id int32) ([]*models.Artist, error) {
l := logger.FromContext(ctx)
l.Debug().Msgf("Fetching artists for track ID %d", id)
rows, err := d.q.GetTrackArtists(ctx, id)
if err != nil {
return nil, fmt.Errorf("GetArtistsForTrack: %w", err)
}
artists := make([]*models.Artist, len(rows))
for i, row := range rows {
artists[i] = &models.Artist{
ID: row.ID,
Name: row.Name,
MbzID: row.MusicBrainzID,
Image: row.Image,
IsPrimary: row.IsPrimary.Valid && row.IsPrimary.Bool,
}
}
return artists, nil
} }

@ -3,6 +3,7 @@ package psql
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"time" "time"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
@ -17,10 +18,11 @@ func (p *Psql) CountListens(ctx context.Context, period db.Period) (int64, error
ListenedAt_2: t2, ListenedAt_2: t2,
}) })
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("CountListens: %w", err)
} }
return count, nil return count, nil
} }
func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error) { func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error) {
t2 := time.Now() t2 := time.Now()
t1 := db.StartTimeFromPeriod(period) t1 := db.StartTimeFromPeriod(period)
@ -29,10 +31,11 @@ func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error)
ListenedAt_2: t2, ListenedAt_2: t2,
}) })
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("CountTracks: %w", err)
} }
return count, nil return count, nil
} }
func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error) { func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error) {
t2 := time.Now() t2 := time.Now()
t1 := db.StartTimeFromPeriod(period) t1 := db.StartTimeFromPeriod(period)
@ -41,10 +44,11 @@ func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error)
ListenedAt_2: t2, ListenedAt_2: t2,
}) })
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("CountAlbums: %w", err)
} }
return count, nil return count, nil
} }
func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error) { func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error) {
t2 := time.Now() t2 := time.Now()
t1 := db.StartTimeFromPeriod(period) t1 := db.StartTimeFromPeriod(period)
@ -53,10 +57,11 @@ func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error
ListenedAt_2: t2, ListenedAt_2: t2,
}) })
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("CountArtists: %w", err)
} }
return count, nil return count, nil
} }
func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64, error) { func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64, error) {
t2 := time.Now() t2 := time.Now()
t1 := db.StartTimeFromPeriod(period) t1 := db.StartTimeFromPeriod(period)
@ -65,10 +70,11 @@ func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64,
ListenedAt_2: t2, ListenedAt_2: t2,
}) })
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("CountTimeListened: %w", err)
} }
return count, nil return count, nil
} }
func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) { func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) {
t2 := time.Now() t2 := time.Now()
t1 := db.StartTimeFromPeriod(opts.Period) t1 := db.StartTimeFromPeriod(opts.Period)
@ -80,7 +86,7 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened
ArtistID: opts.ArtistID, ArtistID: opts.ArtistID,
}) })
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("CountTimeListenedToItem (Artist): %w", err)
} }
return count, nil return count, nil
} else if opts.AlbumID > 0 { } else if opts.AlbumID > 0 {
@ -90,10 +96,9 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened
ReleaseID: opts.AlbumID, ReleaseID: opts.AlbumID,
}) })
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("CountTimeListenedToItem (Album): %w", err)
} }
return count, nil return count, nil
} else if opts.TrackID > 0 { } else if opts.TrackID > 0 {
count, err := p.q.CountTimeListenedToTrack(ctx, repository.CountTimeListenedToTrackParams{ count, err := p.q.CountTimeListenedToTrack(ctx, repository.CountTimeListenedToTrackParams{
ListenedAt: t1, ListenedAt: t1,
@ -101,9 +106,9 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened
ID: opts.TrackID, ID: opts.TrackID,
}) })
if err != nil { if err != nil {
return 0, err return 0, fmt.Errorf("CountTimeListenedToItem (Track): %w", err)
} }
return count, nil return count, nil
} }
return 0, errors.New("an id must be provided") return 0, errors.New("CountTimeListenedToItem: an id must be provided")
} }

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/models" "github.com/gabehf/koito/internal/models"
@ -15,15 +16,15 @@ import (
func (d *Psql) ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error) { func (d *Psql) ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error) {
_, err := d.q.GetReleaseByImageID(ctx, &image) _, err := d.q.GetReleaseByImageID(ctx, &image)
if err == nil { if err == nil {
return true, err return true, nil
} else if !errors.Is(err, pgx.ErrNoRows) { } else if !errors.Is(err, pgx.ErrNoRows) {
return false, err return false, fmt.Errorf("ImageHasAssociation: GetReleaseByImageID: %w", err)
} }
_, err = d.q.GetArtistByImage(ctx, &image) _, err = d.q.GetArtistByImage(ctx, &image)
if err == nil { if err == nil {
return true, err return true, nil
} else if !errors.Is(err, pgx.ErrNoRows) { } else if !errors.Is(err, pgx.ErrNoRows) {
return false, err return false, fmt.Errorf("ImageHasAssociation: GetArtistByImage: %w", err)
} }
return false, nil return false, nil
} }
@ -31,15 +32,15 @@ func (d *Psql) ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool,
func (d *Psql) GetImageSource(ctx context.Context, image uuid.UUID) (string, error) { func (d *Psql) GetImageSource(ctx context.Context, image uuid.UUID) (string, error) {
r, err := d.q.GetReleaseByImageID(ctx, &image) r, err := d.q.GetReleaseByImageID(ctx, &image)
if err == nil { if err == nil {
return r.ImageSource.String, err return r.ImageSource.String, nil
} else if !errors.Is(err, pgx.ErrNoRows) { } else if !errors.Is(err, pgx.ErrNoRows) {
return "", err return "", fmt.Errorf("GetImageSource: GetReleaseByImageID: %w", err)
} }
rr, err := d.q.GetArtistByImage(ctx, &image) rr, err := d.q.GetArtistByImage(ctx, &image)
if err == nil { if err == nil {
return rr.ImageSource.String, err return rr.ImageSource.String, nil
} else if !errors.Is(err, pgx.ErrNoRows) { } else if !errors.Is(err, pgx.ErrNoRows) {
return "", err return "", fmt.Errorf("GetImageSource: GetArtistByImage: %w", err)
} }
return "", nil return "", nil
} }
@ -51,14 +52,13 @@ func (d *Psql) AlbumsWithoutImages(ctx context.Context, from int32) ([]*models.A
ID: from, ID: from,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("AlbumsWithoutImages: GetReleasesWithoutImages: %w", err)
} }
albums := make([]*models.Album, len(rows)) albums := make([]*models.Album, len(rows))
for i, row := range rows { for i, row := range rows {
artists := make([]models.SimpleArtist, 0) var artists []models.SimpleArtist
err = json.Unmarshal(row.Artists, &artists) if err := json.Unmarshal(row.Artists, &artists); err != nil {
if err != nil { l.Err(err).Msgf("AlbumsWithoutImages: error unmarshalling artists for release group with id %d", row.ID)
l.Err(err).Msgf("Error unmarshalling artists for release group with id %d", row.ID)
artists = nil artists = nil
} }
albums[i] = &models.Album{ albums[i] = &models.Album{

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"time" "time"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
@ -18,7 +19,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
offset := (opts.Page - 1) * opts.Limit offset := (opts.Page - 1) * opts.Limit
t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year) t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListensPaginated: %w", err)
} }
if opts.Month == 0 && opts.Year == 0 { if opts.Month == 0 && opts.Year == 0 {
// use period, not date range // use period, not date range
@ -41,7 +42,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
ID: int32(opts.TrackID), ID: int32(opts.TrackID),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListensPaginated: GetLastListensFromTrackPaginated: %w", err)
} }
listens = make([]*models.Listen, len(rows)) listens = make([]*models.Listen, len(rows))
for i, row := range rows { for i, row := range rows {
@ -54,7 +55,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
} }
err = json.Unmarshal(row.Artists, &t.Track.Artists) err = json.Unmarshal(row.Artists, &t.Track.Artists)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err)
} }
listens[i] = t listens[i] = t
} }
@ -64,7 +65,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
TrackID: int32(opts.TrackID), TrackID: int32(opts.TrackID),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListensPaginated: CountListensFromTrack: %w", err)
} }
} else if opts.AlbumID > 0 { } else if opts.AlbumID > 0 {
l.Debug().Msgf("Fetching %d listens with period %s on page %d from range %v to %v", l.Debug().Msgf("Fetching %d listens with period %s on page %d from range %v to %v",
@ -77,7 +78,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
ReleaseID: int32(opts.AlbumID), ReleaseID: int32(opts.AlbumID),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListensPaginated: GetLastListensFromReleasePaginated: %w", err)
} }
listens = make([]*models.Listen, len(rows)) listens = make([]*models.Listen, len(rows))
for i, row := range rows { for i, row := range rows {
@ -90,7 +91,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
} }
err = json.Unmarshal(row.Artists, &t.Track.Artists) err = json.Unmarshal(row.Artists, &t.Track.Artists)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err)
} }
listens[i] = t listens[i] = t
} }
@ -100,7 +101,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
ReleaseID: int32(opts.AlbumID), ReleaseID: int32(opts.AlbumID),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListensPaginated: CountListensFromRelease: %w", err)
} }
} else if opts.ArtistID > 0 { } else if opts.ArtistID > 0 {
l.Debug().Msgf("Fetching %d listens with period %s on page %d from range %v to %v", l.Debug().Msgf("Fetching %d listens with period %s on page %d from range %v to %v",
@ -113,7 +114,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
ArtistID: int32(opts.ArtistID), ArtistID: int32(opts.ArtistID),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListensPaginated: GetLastListensFromArtistPaginated: %w", err)
} }
listens = make([]*models.Listen, len(rows)) listens = make([]*models.Listen, len(rows))
for i, row := range rows { for i, row := range rows {
@ -126,7 +127,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
} }
err = json.Unmarshal(row.Artists, &t.Track.Artists) err = json.Unmarshal(row.Artists, &t.Track.Artists)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err)
} }
listens[i] = t listens[i] = t
} }
@ -136,7 +137,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
ArtistID: int32(opts.ArtistID), ArtistID: int32(opts.ArtistID),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListensPaginated: CountListensFromArtist: %w", err)
} }
} else { } else {
l.Debug().Msgf("Fetching %d listens with period %s on page %d from range %v to %v", l.Debug().Msgf("Fetching %d listens with period %s on page %d from range %v to %v",
@ -148,7 +149,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
Offset: int32(offset), Offset: int32(offset),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListensPaginated: GetLastListensPaginated: %w", err)
} }
listens = make([]*models.Listen, len(rows)) listens = make([]*models.Listen, len(rows))
for i, row := range rows { for i, row := range rows {
@ -161,7 +162,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
} }
err = json.Unmarshal(row.Artists, &t.Track.Artists) err = json.Unmarshal(row.Artists, &t.Track.Artists)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err)
} }
listens[i] = t listens[i] = t
} }
@ -170,7 +171,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
ListenedAt_2: t2, ListenedAt_2: t2,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListensPaginated: CountListens: %w", err)
} }
l.Debug().Msgf("Database responded with %d tracks out of a total %d", len(rows), count) l.Debug().Msgf("Database responded with %d tracks out of a total %d", len(rows), count)
} }

@ -3,6 +3,7 @@ package psql
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/logger"
@ -30,7 +31,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
ReleaseID: opts.AlbumID, ReleaseID: opts.AlbumID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListenActivity: ListenActivityForRelease: %w", err)
} }
listenActivity = make([]db.ListenActivityItem, len(rows)) listenActivity = make([]db.ListenActivityItem, len(rows))
for i, row := range rows { for i, row := range rows {
@ -51,7 +52,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
ArtistID: opts.ArtistID, ArtistID: opts.ArtistID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListenActivity: ListenActivityForArtist: %w", err)
} }
listenActivity = make([]db.ListenActivityItem, len(rows)) listenActivity = make([]db.ListenActivityItem, len(rows))
for i, row := range rows { for i, row := range rows {
@ -72,7 +73,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
ID: opts.TrackID, ID: opts.TrackID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListenActivity: ListenActivityForTrack: %w", err)
} }
listenActivity = make([]db.ListenActivityItem, len(rows)) listenActivity = make([]db.ListenActivityItem, len(rows))
for i, row := range rows { for i, row := range rows {
@ -92,7 +93,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
Column3: stepToInterval(opts.Step), Column3: stepToInterval(opts.Step),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetListenActivity: ListenActivity: %w", err)
} }
listenActivity = make([]db.ListenActivityItem, len(rows)) listenActivity = make([]db.ListenActivityItem, len(rows))
for i, row := range rows { for i, row := range rows {

@ -71,7 +71,7 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage
fromArtists, err := qtx.GetReleaseArtists(ctx, fromId) fromArtists, err := qtx.GetReleaseArtists(ctx, fromId)
if err != nil { if err != nil {
return fmt.Errorf("MergeTracks: GetReleaseArtists: %w", err) return fmt.Errorf("MergeAlbums: GetReleaseArtists: %w", err)
} }
err = qtx.UpdateReleaseForAll(ctx, repository.UpdateReleaseForAllParams{ err = qtx.UpdateReleaseForAll(ctx, repository.UpdateReleaseForAllParams{

@ -34,34 +34,34 @@ func New() (*Psql, error) {
config, err := pgxpool.ParseConfig(cfg.DatabaseUrl()) config, err := pgxpool.ParseConfig(cfg.DatabaseUrl())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse pgx config: %w", err) return nil, fmt.Errorf("psql.New: failed to parse pgx config: %w", err)
} }
config.ConnConfig.ConnectTimeout = 15 * time.Second config.ConnConfig.ConnectTimeout = 15 * time.Second
pool, err := pgxpool.NewWithConfig(ctx, config) pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create pgx pool: %w", err) return nil, fmt.Errorf("psql.New: failed to create pgx pool: %w", err)
} }
if err := pool.Ping(ctx); err != nil { if err := pool.Ping(ctx); err != nil {
pool.Close() pool.Close()
return nil, fmt.Errorf("database not reachable: %w", err) return nil, fmt.Errorf("psql.New: database not reachable: %w", err)
} }
sqlDB, err := sql.Open("pgx", cfg.DatabaseUrl()) sqlDB, err := sql.Open("pgx", cfg.DatabaseUrl())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open db for migrations: %w", err) return nil, fmt.Errorf("psql.New: failed to open db for migrations: %w", err)
} }
_, filename, _, ok := runtime.Caller(0) _, filename, _, ok := runtime.Caller(0)
if !ok { if !ok {
return nil, fmt.Errorf("unable to get caller info") return nil, fmt.Errorf("psql.New: unable to get caller info")
} }
migrationsPath := filepath.Join(filepath.Dir(filename), "..", "..", "..", "db", "migrations") migrationsPath := filepath.Join(filepath.Dir(filename), "..", "..", "..", "db", "migrations")
if err := goose.Up(sqlDB, migrationsPath); err != nil { if err := goose.Up(sqlDB, migrationsPath); err != nil {
return nil, fmt.Errorf("goose failed: %w", err) return nil, fmt.Errorf("psql.New: goose failed: %w", err)
} }
_ = sqlDB.Close() _ = sqlDB.Close()

@ -3,6 +3,7 @@ package psql
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"github.com/gabehf/koito/internal/models" "github.com/gabehf/koito/internal/models"
"github.com/gabehf/koito/internal/repository" "github.com/gabehf/koito/internal/repository"
@ -19,7 +20,7 @@ func (d *Psql) SearchArtists(ctx context.Context, q string) ([]*models.Artist, e
Limit: searchItemLimit, Limit: searchItemLimit,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SearchArtist: SearchArtistsBySubstring: %w", err)
} }
ret := make([]*models.Artist, len(rows)) ret := make([]*models.Artist, len(rows))
for i, row := range rows { for i, row := range rows {
@ -37,7 +38,7 @@ func (d *Psql) SearchArtists(ctx context.Context, q string) ([]*models.Artist, e
Limit: searchItemLimit, Limit: searchItemLimit,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SearchArtist: SearchArtists: %w", err)
} }
ret := make([]*models.Artist, len(rows)) ret := make([]*models.Artist, len(rows))
for i, row := range rows { for i, row := range rows {
@ -59,7 +60,7 @@ func (d *Psql) SearchAlbums(ctx context.Context, q string) ([]*models.Album, err
Limit: searchItemLimit, Limit: searchItemLimit,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SearchAlbums: SearchReleasesBySubstring: %w", err)
} }
ret := make([]*models.Album, len(rows)) ret := make([]*models.Album, len(rows))
for i, row := range rows { for i, row := range rows {
@ -72,7 +73,7 @@ func (d *Psql) SearchAlbums(ctx context.Context, q string) ([]*models.Album, err
} }
err = json.Unmarshal(row.Artists, &ret[i].Artists) err = json.Unmarshal(row.Artists, &ret[i].Artists)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SearchAlbums: Unmarshal: %w", err)
} }
} }
return ret, nil return ret, nil
@ -82,7 +83,7 @@ func (d *Psql) SearchAlbums(ctx context.Context, q string) ([]*models.Album, err
Limit: searchItemLimit, Limit: searchItemLimit,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SearchAlbums: SearchReleases: %w", err)
} }
ret := make([]*models.Album, len(rows)) ret := make([]*models.Album, len(rows))
for i, row := range rows { for i, row := range rows {
@ -95,7 +96,7 @@ func (d *Psql) SearchAlbums(ctx context.Context, q string) ([]*models.Album, err
} }
err = json.Unmarshal(row.Artists, &ret[i].Artists) err = json.Unmarshal(row.Artists, &ret[i].Artists)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SearchAlbums: Unmarshal: %w", err)
} }
} }
return ret, nil return ret, nil
@ -109,7 +110,7 @@ func (d *Psql) SearchTracks(ctx context.Context, q string) ([]*models.Track, err
Limit: searchItemLimit, Limit: searchItemLimit,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SearchTracks: SearchTracksBySubstring: %w", err)
} }
ret := make([]*models.Track, len(rows)) ret := make([]*models.Track, len(rows))
for i, row := range rows { for i, row := range rows {
@ -121,7 +122,7 @@ func (d *Psql) SearchTracks(ctx context.Context, q string) ([]*models.Track, err
} }
err = json.Unmarshal(row.Artists, &ret[i].Artists) err = json.Unmarshal(row.Artists, &ret[i].Artists)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SearchTracks: Unmarshal: %w", err)
} }
} }
return ret, nil return ret, nil
@ -131,7 +132,7 @@ func (d *Psql) SearchTracks(ctx context.Context, q string) ([]*models.Track, err
Limit: searchItemLimit, Limit: searchItemLimit,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SearchTracks: SearchTracks: %w", err)
} }
ret := make([]*models.Track, len(rows)) ret := make([]*models.Track, len(rows))
for i, row := range rows { for i, row := range rows {
@ -143,7 +144,7 @@ func (d *Psql) SearchTracks(ctx context.Context, q string) ([]*models.Track, err
} }
err = json.Unmarshal(row.Artists, &ret[i].Artists) err = json.Unmarshal(row.Artists, &ret[i].Artists)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SearchTracks: Unmarshal: %w", err)
} }
} }
return ret, nil return ret, nil

@ -3,6 +3,7 @@ package psql
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"time" "time"
"github.com/gabehf/koito/internal/models" "github.com/gabehf/koito/internal/models"
@ -19,7 +20,7 @@ func (d *Psql) SaveSession(ctx context.Context, userID int32, expiresAt time.Tim
Persistent: persistent, Persistent: persistent,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SaveSession: InsertSession: %w", err)
} }
return &models.Session{ return &models.Session{
ID: session.ID, ID: session.ID,
@ -47,7 +48,7 @@ func (d *Psql) GetUserBySession(ctx context.Context, sessionId uuid.UUID) (*mode
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
} else if err != nil { } else if err != nil {
return nil, err return nil, fmt.Errorf("SaveSession: GetUserBySession: %w", err)
} }
return &models.User{ return &models.User{

@ -3,6 +3,7 @@ package psql
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"time" "time"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
@ -17,7 +18,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
offset := (opts.Page - 1) * opts.Limit offset := (opts.Page - 1) * opts.Limit
t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year) t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopAlbumsPaginated: %w", err)
} }
if opts.Month == 0 && opts.Year == 0 { if opts.Month == 0 && opts.Year == 0 {
// use period, not date range // use period, not date range
@ -43,7 +44,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
ListenedAt_2: t2, ListenedAt_2: t2,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopAlbumsPaginated: GetTopReleasesFromArtist: %w", err)
} }
rgs = make([]*models.Album, len(rows)) rgs = make([]*models.Album, len(rows))
l.Debug().Msgf("Database responded with %d items", len(rows)) l.Debug().Msgf("Database responded with %d items", len(rows))
@ -52,7 +53,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
err = json.Unmarshal(v.Artists, &artists) err = json.Unmarshal(v.Artists, &artists)
if err != nil { if err != nil {
l.Err(err).Msgf("Error unmarshalling artists for release group with id %d", v.ID) l.Err(err).Msgf("Error unmarshalling artists for release group with id %d", v.ID)
artists = nil return nil, fmt.Errorf("GetTopAlbumsPaginated: Unmarshal: %w", err)
} }
rgs[i] = &models.Album{ rgs[i] = &models.Album{
ID: v.ID, ID: v.ID,
@ -66,7 +67,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
} }
count, err = d.q.CountReleasesFromArtist(ctx, int32(opts.ArtistID)) count, err = d.q.CountReleasesFromArtist(ctx, int32(opts.ArtistID))
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopAlbumsPaginated: CountReleasesFromArtist: %w", err)
} }
} else { } else {
l.Debug().Msgf("Fetching top %d albums with period %s on page %d from range %v to %v", l.Debug().Msgf("Fetching top %d albums with period %s on page %d from range %v to %v",
@ -78,7 +79,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
Offset: int32(offset), Offset: int32(offset),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopAlbumsPaginated: GetTopReleasesPaginated: %w", err)
} }
rgs = make([]*models.Album, len(rows)) rgs = make([]*models.Album, len(rows))
l.Debug().Msgf("Database responded with %d items", len(rows)) l.Debug().Msgf("Database responded with %d items", len(rows))
@ -87,7 +88,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
err = json.Unmarshal(row.Artists, &artists) err = json.Unmarshal(row.Artists, &artists)
if err != nil { if err != nil {
l.Err(err).Msgf("Error unmarshalling artists for release group with id %d", row.ID) l.Err(err).Msgf("Error unmarshalling artists for release group with id %d", row.ID)
artists = nil return nil, fmt.Errorf("GetTopAlbumsPaginated: Unmarshal: %w", err)
} }
t := &models.Album{ t := &models.Album{
Title: row.Title, Title: row.Title,
@ -105,7 +106,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
ListenedAt_2: t2, ListenedAt_2: t2,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopAlbumsPaginated: CountTopReleases: %w", err)
} }
l.Debug().Msgf("Database responded with %d albums out of a total %d", len(rows), count) l.Debug().Msgf("Database responded with %d albums out of a total %d", len(rows), count)
} }

@ -2,6 +2,7 @@ package psql
import ( import (
"context" "context"
"fmt"
"time" "time"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
@ -16,7 +17,7 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts)
offset := (opts.Page - 1) * opts.Limit offset := (opts.Page - 1) * opts.Limit
t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year) t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopArtistsPaginated: %w", err)
} }
if opts.Month == 0 && opts.Year == 0 { if opts.Month == 0 && opts.Year == 0 {
// use period, not date range // use period, not date range
@ -35,7 +36,7 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts)
Offset: int32(offset), Offset: int32(offset),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopArtistsPaginated: GetTopArtistsPaginated: %w", err)
} }
rgs := make([]*models.Artist, len(rows)) rgs := make([]*models.Artist, len(rows))
for i, row := range rows { for i, row := range rows {
@ -53,7 +54,7 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts)
ListenedAt_2: t2, ListenedAt_2: t2,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopArtistsPaginated: CountTopArtists: %w", err)
} }
l.Debug().Msgf("Database responded with %d artists out of a total %d", len(rows), count) l.Debug().Msgf("Database responded with %d artists out of a total %d", len(rows), count)

@ -3,6 +3,7 @@ package psql
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"time" "time"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
@ -17,7 +18,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
offset := (opts.Page - 1) * opts.Limit offset := (opts.Page - 1) * opts.Limit
t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year) t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopTracksPaginated: %w", err)
} }
if opts.Month == 0 && opts.Year == 0 { if opts.Month == 0 && opts.Year == 0 {
// use period, not date range // use period, not date range
@ -40,7 +41,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
ReleaseID: int32(opts.AlbumID), ReleaseID: int32(opts.AlbumID),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopTracksPaginated: GetTopTracksInReleasePaginated: %w", err)
} }
tracks = make([]*models.Track, len(rows)) tracks = make([]*models.Track, len(rows))
for i, row := range rows { for i, row := range rows {
@ -48,7 +49,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
err = json.Unmarshal(row.Artists, &artists) err = json.Unmarshal(row.Artists, &artists)
if err != nil { if err != nil {
l.Err(err).Msgf("Error unmarshalling artists for track with id %d", row.ID) l.Err(err).Msgf("Error unmarshalling artists for track with id %d", row.ID)
artists = nil return nil, fmt.Errorf("GetTopTracksPaginated: Unmarshal: %w", err)
} }
t := &models.Track{ t := &models.Track{
Title: row.Title, Title: row.Title,
@ -80,7 +81,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
ArtistID: int32(opts.ArtistID), ArtistID: int32(opts.ArtistID),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopTracksPaginated: GetTopTracksByArtistPaginated: %w", err)
} }
tracks = make([]*models.Track, len(rows)) tracks = make([]*models.Track, len(rows))
for i, row := range rows { for i, row := range rows {
@ -88,7 +89,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
err = json.Unmarshal(row.Artists, &artists) err = json.Unmarshal(row.Artists, &artists)
if err != nil { if err != nil {
l.Err(err).Msgf("Error unmarshalling artists for track with id %d", row.ID) l.Err(err).Msgf("Error unmarshalling artists for track with id %d", row.ID)
artists = nil return nil, fmt.Errorf("GetTopTracksPaginated: Unmarshal: %w", err)
} }
t := &models.Track{ t := &models.Track{
Title: row.Title, Title: row.Title,
@ -107,7 +108,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
ArtistID: int32(opts.ArtistID), ArtistID: int32(opts.ArtistID),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopTracksPaginated: CountTopTracksByArtist: %w", err)
} }
} else { } else {
l.Debug().Msgf("Fetching top %d tracks with period %s on page %d from range %v to %v", l.Debug().Msgf("Fetching top %d tracks with period %s on page %d from range %v to %v",
@ -119,7 +120,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
Offset: int32(offset), Offset: int32(offset),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopTracksPaginated: GetTopTracksPaginated: %w", err)
} }
tracks = make([]*models.Track, len(rows)) tracks = make([]*models.Track, len(rows))
for i, row := range rows { for i, row := range rows {
@ -127,7 +128,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
err = json.Unmarshal(row.Artists, &artists) err = json.Unmarshal(row.Artists, &artists)
if err != nil { if err != nil {
l.Err(err).Msgf("Error unmarshalling artists for track with id %d", row.ID) l.Err(err).Msgf("Error unmarshalling artists for track with id %d", row.ID)
artists = nil return nil, fmt.Errorf("GetTopTracksPaginated: Unmarshal: %w", err)
} }
t := &models.Track{ t := &models.Track{
Title: row.Title, Title: row.Title,
@ -145,7 +146,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
ListenedAt_2: t2, ListenedAt_2: t2,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTopTracksPaginated: CountTopTracks: %w", err)
} }
l.Debug().Msgf("Database responded with %d tracks out of a total %d", len(rows), count) l.Debug().Msgf("Database responded with %d tracks out of a total %d", len(rows), count)
} }

@ -3,6 +3,7 @@ package psql
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strings" "strings"
"time" "time"
@ -23,7 +24,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
l.Debug().Msgf("Fetching track from DB with id %d", opts.ID) l.Debug().Msgf("Fetching track from DB with id %d", opts.ID)
t, err := d.q.GetTrack(ctx, opts.ID) t, err := d.q.GetTrack(ctx, opts.ID)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTrack: GetTrack By ID: %w", err)
} }
track = models.Track{ track = models.Track{
ID: t.ID, ID: t.ID,
@ -37,7 +38,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
l.Debug().Msgf("Fetching track from DB with MusicBrainz ID %s", opts.MusicBrainzID) l.Debug().Msgf("Fetching track from DB with MusicBrainz ID %s", opts.MusicBrainzID)
t, err := d.q.GetTrackByMbzID(ctx, &opts.MusicBrainzID) t, err := d.q.GetTrackByMbzID(ctx, &opts.MusicBrainzID)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTrack: GetTrackByMbzID: %w", err)
} }
track = models.Track{ track = models.Track{
ID: t.ID, ID: t.ID,
@ -53,7 +54,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
Column2: opts.ArtistIDs, Column2: opts.ArtistIDs,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTrack: GetTrackByTitleAndArtists: %w", err)
} }
track = models.Track{ track = models.Track{
ID: t.ID, ID: t.ID,
@ -63,7 +64,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
Duration: t.Duration, Duration: t.Duration,
} }
} else { } else {
return nil, errors.New("insufficient information to get track") return nil, errors.New("GetTrack: insufficient information to get track")
} }
count, err := d.q.CountListensFromTrack(ctx, repository.CountListensFromTrackParams{ count, err := d.q.CountListensFromTrack(ctx, repository.CountListensFromTrackParams{
@ -72,7 +73,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
TrackID: track.ID, TrackID: track.ID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTrack: CountListensFromTrack: %w", err)
} }
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{ seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
@ -80,7 +81,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
TrackID: track.ID, TrackID: track.ID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err)
} }
track.ListenCount = count track.ListenCount = count
@ -97,20 +98,20 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
insertMbzID = &opts.RecordingMbzID insertMbzID = &opts.RecordingMbzID
} }
if len(opts.ArtistIDs) < 1 { if len(opts.ArtistIDs) < 1 {
return nil, errors.New("required parameter 'ArtistIDs' missing") return nil, errors.New("SaveTrack: required parameter 'ArtistIDs' missing")
} }
for _, aid := range opts.ArtistIDs { for _, aid := range opts.ArtistIDs {
if aid == 0 { if aid == 0 {
return nil, errors.New("none of 'ArtistIDs' may be 0") return nil, errors.New("SaveTrack: none of 'ArtistIDs' may be 0")
} }
} }
if opts.AlbumID == 0 { if opts.AlbumID == 0 {
return nil, errors.New("required parameter 'AlbumID' missing") return nil, errors.New("SaveTrack: required parameter 'AlbumID' missing")
} }
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return nil, err return nil, fmt.Errorf("SaveTrack: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
@ -120,7 +121,7 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
ReleaseID: opts.AlbumID, ReleaseID: opts.AlbumID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SaveTrack: InsertTrack: %w", err)
} }
// insert associated artists // insert associated artists
for _, aid := range opts.ArtistIDs { for _, aid := range opts.ArtistIDs {
@ -129,7 +130,7 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
TrackID: trackRow.ID, TrackID: trackRow.ID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SaveTrack: AssociateArtistToTrack: %w", err)
} }
} }
// insert primary alias // insert primary alias
@ -140,11 +141,11 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
IsPrimary: true, IsPrimary: true,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SaveTrack: InsertTrackAlias: %w", err)
} }
err = tx.Commit(ctx) err = tx.Commit(ctx)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SaveTrack: Commit: %w", err)
} }
return &models.Track{ return &models.Track{
ID: trackRow.ID, ID: trackRow.ID,
@ -156,12 +157,12 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error { func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
if opts.ID == 0 { if opts.ID == 0 {
return errors.New("track id not specified") return errors.New("UpdateTrack: track id not specified")
} }
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("UpdateTrack: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
@ -172,7 +173,7 @@ func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error {
MusicBrainzID: &opts.MusicBrainzID, MusicBrainzID: &opts.MusicBrainzID,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("UpdateTrack: UpdateTrackMbzID: %w", err)
} }
} }
if opts.Duration != 0 { if opts.Duration != 0 {
@ -182,7 +183,7 @@ func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error {
Duration: opts.Duration, Duration: opts.Duration,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("UpdateTrack: UpdateTrackDuration: %w", err)
} }
} }
return tx.Commit(ctx) return tx.Commit(ctx)
@ -191,18 +192,18 @@ func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error {
func (d *Psql) SaveTrackAliases(ctx context.Context, id int32, aliases []string, source string) error { func (d *Psql) SaveTrackAliases(ctx context.Context, id int32, aliases []string, source string) error {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
if id == 0 { if id == 0 {
return errors.New("track id not specified") return errors.New("SaveTrackAliases: track id not specified")
} }
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("SaveTrackAliases: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
existing, err := qtx.GetAllTrackAliases(ctx, id) existing, err := qtx.GetAllTrackAliases(ctx, id)
if err != nil { if err != nil {
return err return fmt.Errorf("SaveTrackAliases: GetAllTrackAliases: %w", err)
} }
for _, v := range existing { for _, v := range existing {
aliases = append(aliases, v.Alias) aliases = append(aliases, v.Alias)
@ -219,7 +220,7 @@ func (d *Psql) SaveTrackAliases(ctx context.Context, id int32, aliases []string,
IsPrimary: false, IsPrimary: false,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("SaveTrackAliases: InsertTrackAlias: %w", err)
} }
} }
return tx.Commit(ctx) return tx.Commit(ctx)
@ -239,7 +240,7 @@ func (d *Psql) DeleteTrackAlias(ctx context.Context, id int32, alias string) err
func (d *Psql) GetAllTrackAliases(ctx context.Context, id int32) ([]models.Alias, error) { func (d *Psql) GetAllTrackAliases(ctx context.Context, id int32) ([]models.Alias, error) {
rows, err := d.q.GetAllTrackAliases(ctx, id) rows, err := d.q.GetAllTrackAliases(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetAllTrackAliases: GetAllTrackAliases: %w", err)
} }
aliases := make([]models.Alias, len(rows)) aliases := make([]models.Alias, len(rows))
for i, row := range rows { for i, row := range rows {
@ -261,14 +262,14 @@ func (d *Psql) SetPrimaryTrackAlias(ctx context.Context, id int32, alias string)
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("SetPrimaryTrackAlias: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
// get all aliases // get all aliases
aliases, err := qtx.GetAllTrackAliases(ctx, id) aliases, err := qtx.GetAllTrackAliases(ctx, id)
if err != nil { if err != nil {
return err return fmt.Errorf("SetPrimaryTrackAlias: GetAllTrackAliases: %w", err)
} }
primary := "" primary := ""
exists := false exists := false
@ -293,7 +294,7 @@ func (d *Psql) SetPrimaryTrackAlias(ctx context.Context, id int32, alias string)
IsPrimary: true, IsPrimary: true,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("SetPrimaryTrackAlias: SetTrackAliasPrimaryStatus: %w", err)
} }
err = qtx.SetTrackAliasPrimaryStatus(ctx, repository.SetTrackAliasPrimaryStatusParams{ err = qtx.SetTrackAliasPrimaryStatus(ctx, repository.SetTrackAliasPrimaryStatusParams{
TrackID: id, TrackID: id,
@ -301,7 +302,61 @@ func (d *Psql) SetPrimaryTrackAlias(ctx context.Context, id int32, alias string)
IsPrimary: false, IsPrimary: false,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("SetPrimaryTrackAlias: SetTrackAliasPrimaryStatus: %w", err)
}
return tx.Commit(ctx)
}
func (d *Psql) SetPrimaryTrackArtist(ctx context.Context, id int32, artistId int32, value bool) error {
l := logger.FromContext(ctx)
if id == 0 {
return errors.New("artist id not specified")
}
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return fmt.Errorf("SetPrimaryTrackArtist: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
// get all artists
artists, err := qtx.GetTrackArtists(ctx, id)
if err != nil {
return fmt.Errorf("SetPrimaryTrackArtist: GetTrackArtists: %w", err)
}
var primary int32
for _, v := range artists {
// i dont get it??? is_primary is not a nullable column??? why use pgtype.Bool???
// why not just use boolean??? is sqlc stupid??? am i stupid???????
if v.IsPrimary.Valid && v.IsPrimary.Bool {
primary = v.ID
}
}
if value && primary == artistId {
// no-op
return nil
}
l.Debug().Msgf("Marking artist with id %d as 'primary = %v' on track with id %d", artistId, value, id)
err = qtx.UpdateTrackPrimaryArtist(ctx, repository.UpdateTrackPrimaryArtistParams{
TrackID: id,
ArtistID: artistId,
IsPrimary: value,
})
if err != nil {
return fmt.Errorf("SetPrimaryTrackArtist: UpdateTrackPrimaryArtist: %w", err)
}
if value && primary != 0 {
l.Debug().Msgf("Unmarking artist with id %d as primary on track with id %d", primary, id)
// if we were marking a new one as primary and there was already one marked as primary,
// unmark that one as there can only be one
err = qtx.UpdateTrackPrimaryArtist(ctx, repository.UpdateTrackPrimaryArtistParams{
TrackID: id,
ArtistID: primary,
IsPrimary: false,
})
if err != nil {
return fmt.Errorf("SetPrimaryTrackArtist: UpdateTrackPrimaryArtist: %w", err)
}
} }
return tx.Commit(ctx) return tx.Commit(ctx)
} }

@ -3,6 +3,7 @@ package psql
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"regexp" "regexp"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@ -21,7 +22,7 @@ func (d *Psql) GetUserByUsername(ctx context.Context, username string) (*models.
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
} else if err != nil { } else if err != nil {
return nil, err return nil, fmt.Errorf("GetUserByUsername: %w", err)
} }
return &models.User{ return &models.User{
ID: row.ID, ID: row.ID,
@ -37,7 +38,7 @@ func (d *Psql) GetUserByApiKey(ctx context.Context, key string) (*models.User, e
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
} else if err != nil { } else if err != nil {
return nil, err return nil, fmt.Errorf("GetUserByApiKey: %w", err)
} }
return &models.User{ return &models.User{
ID: row.ID, ID: row.ID,
@ -52,12 +53,12 @@ func (d *Psql) SaveUser(ctx context.Context, opts db.SaveUserOpts) (*models.User
err := ValidateUsername(opts.Username) err := ValidateUsername(opts.Username)
if err != nil { if err != nil {
l.Debug().AnErr("validator_notice", err).Msgf("Username failed validation: %s", opts.Username) l.Debug().AnErr("validator_notice", err).Msgf("Username failed validation: %s", opts.Username)
return nil, err return nil, fmt.Errorf("SaveUser: ValidateUsername: %w", err)
} }
pw, err := ValidateAndNormalizePassword(opts.Password) pw, err := ValidateAndNormalizePassword(opts.Password)
if err != nil { if err != nil {
l.Debug().AnErr("validator_notice", err).Msgf("Password failed validation") l.Debug().AnErr("validator_notice", err).Msgf("Password failed validation")
return nil, err return nil, fmt.Errorf("SaveUser: ValidateAndNormalizePassword: %w", err)
} }
if opts.Role == "" { if opts.Role == "" {
opts.Role = models.UserRoleUser opts.Role = models.UserRoleUser
@ -65,7 +66,7 @@ func (d *Psql) SaveUser(ctx context.Context, opts db.SaveUserOpts) (*models.User
hashPw, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) hashPw, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to generate hashed password") l.Err(err).Msg("Failed to generate hashed password")
return nil, err return nil, fmt.Errorf("SaveUser: bcrypt.GenerateFromPassword: %w", err)
} }
u, err := d.q.InsertUser(ctx, repository.InsertUserParams{ u, err := d.q.InsertUser(ctx, repository.InsertUserParams{
Username: strings.ToLower(opts.Username), Username: strings.ToLower(opts.Username),
@ -73,7 +74,7 @@ func (d *Psql) SaveUser(ctx context.Context, opts db.SaveUserOpts) (*models.User
Role: repository.Role(opts.Role), Role: repository.Role(opts.Role),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SaveUser: InsertUser: %w", err)
} }
return &models.User{ return &models.User{
ID: u.ID, ID: u.ID,
@ -88,7 +89,7 @@ func (d *Psql) SaveApiKey(ctx context.Context, opts db.SaveApiKeyOpts) (*models.
UserID: opts.UserID, UserID: opts.UserID,
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("SaveApiKey: InsertApiKey: %w", err)
} }
return &models.ApiKey{ return &models.ApiKey{
ID: row.ID, ID: row.ID,
@ -107,7 +108,7 @@ func (d *Psql) UpdateUser(ctx context.Context, opts db.UpdateUserOpts) error {
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("UpdateUser: BeginTx: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
@ -115,33 +116,33 @@ func (d *Psql) UpdateUser(ctx context.Context, opts db.UpdateUserOpts) error {
err := ValidateUsername(opts.Username) err := ValidateUsername(opts.Username)
if err != nil { if err != nil {
l.Debug().AnErr("validator_notice", err).Msgf("Username failed validation: %s", opts.Username) l.Debug().AnErr("validator_notice", err).Msgf("Username failed validation: %s", opts.Username)
return err return fmt.Errorf("UpdateUser: ValidateUsername: %w", err)
} }
err = qtx.UpdateUserUsername(ctx, repository.UpdateUserUsernameParams{ err = qtx.UpdateUserUsername(ctx, repository.UpdateUserUsernameParams{
ID: opts.ID, ID: opts.ID,
Username: opts.Username, Username: opts.Username,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("UpdateUser: UpdateUserUsername: %w", err)
} }
} }
if opts.Password != "" { if opts.Password != "" {
pw, err := ValidateAndNormalizePassword(opts.Password) pw, err := ValidateAndNormalizePassword(opts.Password)
if err != nil { if err != nil {
l.Debug().AnErr("validator_notice", err).Msgf("Password failed validation") l.Debug().AnErr("validator_notice", err).Msgf("Password failed validation")
return err return fmt.Errorf("UpdateUser: ValidateAndNormalizePassword: %w", err)
} }
hashPw, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) hashPw, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to generate hashed password") l.Err(err).Msg("Failed to generate hashed password")
return err return fmt.Errorf("UpdateUser: bcrypt.GenerateFromPassword: %w", err)
} }
err = qtx.UpdateUserPassword(ctx, repository.UpdateUserPasswordParams{ err = qtx.UpdateUserPassword(ctx, repository.UpdateUserPasswordParams{
ID: opts.ID, ID: opts.ID,
Password: hashPw, Password: hashPw,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("UpdateUser: UpdateUserPassword: %w", err)
} }
} }
return tx.Commit(ctx) return tx.Commit(ctx)
@ -150,7 +151,7 @@ func (d *Psql) UpdateUser(ctx context.Context, opts db.UpdateUserOpts) error {
func (d *Psql) GetApiKeysByUserID(ctx context.Context, id int32) ([]models.ApiKey, error) { func (d *Psql) GetApiKeysByUserID(ctx context.Context, id int32) ([]models.ApiKey, error) {
rows, err := d.q.GetAllApiKeysByUserID(ctx, id) rows, err := d.q.GetAllApiKeysByUserID(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetApiKeysByUserID: %w", err)
} }
keys := make([]models.ApiKey, len(rows)) keys := make([]models.ApiKey, len(rows))
for i, row := range rows { for i, row := range rows {

@ -53,7 +53,7 @@ func NewDeezerClient() *DeezerClient {
ret := new(DeezerClient) ret := new(DeezerClient)
ret.url = deezerBaseUrl ret.url = deezerBaseUrl
ret.userAgent = cfg.UserAgent() ret.userAgent = cfg.UserAgent()
ret.requestQueue = queue.NewRequestQueue(1, 1) ret.requestQueue = queue.NewRequestQueue(5, 5)
return ret return ret
} }
@ -92,19 +92,19 @@ func (c *DeezerClient) getEntity(ctx context.Context, endpoint string, result an
l.Debug().Msgf("Sending request to ImageSrc: GET %s", url) l.Debug().Msgf("Sending request to ImageSrc: GET %s", url)
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
return err return fmt.Errorf("getEntity: %w", err)
} }
l.Debug().Msg("Adding ImageSrc request to queue") l.Debug().Msg("Adding ImageSrc request to queue")
body, err := c.queue(ctx, req) body, err := c.queue(ctx, req)
if err != nil { if err != nil {
l.Err(err).Msg("Deezer request failed") l.Err(err).Msg("Deezer request failed")
return err return fmt.Errorf("getEntity: %w", err)
} }
err = json.Unmarshal(body, result) err = json.Unmarshal(body, result)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to unmarshal Deezer response") l.Err(err).Msg("Failed to unmarshal Deezer response")
return err return fmt.Errorf("getEntity: %w", err)
} }
return nil return nil
@ -121,10 +121,10 @@ func (c *DeezerClient) GetArtistImages(ctx context.Context, aliases []string) (s
for _, a := range aliasesAscii { for _, a := range aliasesAscii {
err := c.getEntity(ctx, fmt.Sprintf(artistImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"", a))), resp) err := c.getEntity(ctx, fmt.Sprintf(artistImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"", a))), resp)
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("GetArtistImages: %w", err)
} }
if len(resp.Data) < 1 { if len(resp.Data) < 1 {
return "", errors.New("artist image not found") return "", errors.New("GetArtistImages: artist image not found")
} }
for _, v := range resp.Data { for _, v := range resp.Data {
if strings.EqualFold(v.Name, a) { if strings.EqualFold(v.Name, a) {
@ -139,10 +139,10 @@ func (c *DeezerClient) GetArtistImages(ctx context.Context, aliases []string) (s
for _, a := range utils.RemoveInBoth(aliasesUniq, aliasesAscii) { for _, a := range utils.RemoveInBoth(aliasesUniq, aliasesAscii) {
err := c.getEntity(ctx, fmt.Sprintf(artistImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"", a))), resp) err := c.getEntity(ctx, fmt.Sprintf(artistImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"", a))), resp)
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("GetArtistImages: %w", err)
} }
if len(resp.Data) < 1 { if len(resp.Data) < 1 {
return "", errors.New("artist image not found") return "", errors.New("GetArtistImages: artist image not found")
} }
for _, v := range resp.Data { for _, v := range resp.Data {
if strings.EqualFold(v.Name, a) { if strings.EqualFold(v.Name, a) {
@ -152,7 +152,7 @@ func (c *DeezerClient) GetArtistImages(ctx context.Context, aliases []string) (s
} }
} }
} }
return "", errors.New("artist image not found") return "", errors.New("GetArtistImages: artist image not found")
} }
func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, album string) (string, error) { func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, album string) (string, error) {
@ -163,7 +163,7 @@ func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, alb
for _, alias := range artists { for _, alias := range artists {
err := c.getEntity(ctx, fmt.Sprintf(albumImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"album:\"%s\"", alias, album))), resp) err := c.getEntity(ctx, fmt.Sprintf(albumImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"album:\"%s\"", alias, album))), resp)
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("GetAlbumImages: %w", err)
} }
if len(resp.Data) > 0 { if len(resp.Data) > 0 {
for _, v := range resp.Data { for _, v := range resp.Data {
@ -179,7 +179,7 @@ func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, alb
// if none are found, try to find an album just by album title // if none are found, try to find an album just by album title
err := c.getEntity(ctx, fmt.Sprintf(albumImageEndpoint, url.QueryEscape(fmt.Sprintf("album:\"%s\"", album))), resp) err := c.getEntity(ctx, fmt.Sprintf(albumImageEndpoint, url.QueryEscape(fmt.Sprintf("album:\"%s\"", album))), resp)
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("GetAlbumImages: %w", err)
} }
for _, v := range resp.Data { for _, v := range resp.Data {
if strings.EqualFold(v.Title, album) { if strings.EqualFold(v.Title, album) {
@ -189,5 +189,5 @@ func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, alb
} }
} }
return "", errors.New("album image not found") return "", errors.New("GetAlbumImages: album image not found")
} }

@ -64,7 +64,7 @@ func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
} }
return img, nil return img, nil
} }
l.Warn().Msg("No image providers are enabled") l.Warn().Msg("GetArtistImage: No image providers are enabled")
return "", nil return "", nil
} }
func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) { func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) {
@ -102,6 +102,6 @@ func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) {
} }
return img, nil return img, nil
} }
l.Warn().Msg("No image providers are enabled") l.Warn().Msg("GetAlbumImage: No image providers are enabled")
return "", nil return "", nil
} }

@ -3,6 +3,7 @@ package importer
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path" "path"
"strconv" "strconv"
@ -46,7 +47,7 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall
file, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename)) file, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename))
if err != nil { if err != nil {
l.Err(err).Msgf("Failed to read import file: %s", filename) l.Err(err).Msgf("Failed to read import file: %s", filename)
return err return fmt.Errorf("ImportLastFMFile: %w", err)
} }
defer file.Close() defer file.Close()
var throttleFunc = func() {} var throttleFunc = func() {}
@ -58,7 +59,7 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall
export := make([]LastFMExportPage, 0) export := make([]LastFMExportPage, 0)
err = json.NewDecoder(file).Decode(&export) err = json.NewDecoder(file).Decode(&export)
if err != nil { if err != nil {
return err return fmt.Errorf("ImportLastFMFile: %w", err)
} }
count := 0 count := 0
for _, item := range export { for _, item := range export {
@ -88,7 +89,8 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall
if err != nil { if err != nil {
ts, err = time.Parse("02 Jan 2006, 15:04", track.Date.Text) ts, err = time.Parse("02 Jan 2006, 15:04", track.Date.Text)
if err != nil { if err != nil {
ts = time.Now().UTC() l.Err(err).Msg("Could not parse time from listen activity, skipping...")
continue
} }
} else { } else {
ts = time.Unix(unix, 0).UTC() ts = time.Unix(unix, 0).UTC()
@ -116,11 +118,12 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall
Client: "lastfm", Client: "lastfm",
Time: ts, Time: ts,
UserID: 1, UserID: 1,
SkipCacheImage: !cfg.FetchImagesDuringImport(),
} }
err = catalog.SubmitListen(ctx, store, opts) err = catalog.SubmitListen(ctx, store, opts)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to import LastFM playback item") l.Err(err).Msg("Failed to import LastFM playback item")
return err return fmt.Errorf("ImportLastFMFile: %w", err)
} }
count++ count++
throttleFunc() throttleFunc()

@ -141,11 +141,12 @@ func ImportListenBrainzFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrai
Time: ts, Time: ts,
UserID: 1, UserID: 1,
Client: client, Client: client,
SkipCacheImage: !cfg.FetchImagesDuringImport(),
} }
err = catalog.SubmitListen(ctx, store, opts) err = catalog.SubmitListen(ctx, store, opts)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to import LastFM playback item") l.Err(err).Msg("Failed to import LastFM playback item")
return err return fmt.Errorf("ImportListenBrainzFile: %w", err)
} }
count++ count++
throttleFunc() throttleFunc()

@ -3,6 +3,7 @@ package importer
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path" "path"
"strings" "strings"
@ -37,7 +38,7 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error {
file, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename)) file, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename))
if err != nil { if err != nil {
l.Err(err).Msgf("Failed to read import file: %s", filename) l.Err(err).Msgf("Failed to read import file: %s", filename)
return err return fmt.Errorf("ImportMalojaFile: %w", err)
} }
defer file.Close() defer file.Close()
var throttleFunc = func() {} var throttleFunc = func() {}
@ -49,7 +50,7 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error {
export := new(MalojaExport) export := new(MalojaExport)
err = json.NewDecoder(file).Decode(&export) err = json.NewDecoder(file).Decode(&export)
if err != nil { if err != nil {
return err return fmt.Errorf("ImportMalojaFile: %w", err)
} }
for _, item := range export.Scrobbles { for _, item := range export.Scrobbles {
martists := make([]string, 0) martists := make([]string, 0)
@ -79,11 +80,12 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error {
Time: ts.Local(), Time: ts.Local(),
Client: "maloja", Client: "maloja",
UserID: 1, UserID: 1,
SkipCacheImage: !cfg.FetchImagesDuringImport(),
} }
err = catalog.SubmitListen(ctx, store, opts) err = catalog.SubmitListen(ctx, store, opts)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to import maloja playback item") l.Err(err).Msg("Failed to import maloja playback item")
return err return fmt.Errorf("ImportMalojaFile: %w", err)
} }
throttleFunc() throttleFunc()
} }

@ -3,6 +3,7 @@ package importer
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path" "path"
"time" "time"
@ -29,7 +30,7 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error
file, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename)) file, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename))
if err != nil { if err != nil {
l.Err(err).Msgf("Failed to read import file: %s", filename) l.Err(err).Msgf("Failed to read import file: %s", filename)
return err return fmt.Errorf("ImportSpotifyFile: %w", err)
} }
defer file.Close() defer file.Close()
var throttleFunc = func() {} var throttleFunc = func() {}
@ -41,7 +42,7 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error
export := make([]SpotifyExportItem, 0) export := make([]SpotifyExportItem, 0)
err = json.NewDecoder(file).Decode(&export) err = json.NewDecoder(file).Decode(&export)
if err != nil { if err != nil {
return err return fmt.Errorf("ImportSpotifyFile: %w", err)
} }
for _, item := range export { for _, item := range export {
@ -66,11 +67,12 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error
Time: item.Timestamp, Time: item.Timestamp,
Client: "spotify", Client: "spotify",
UserID: 1, UserID: 1,
SkipCacheImage: !cfg.FetchImagesDuringImport(),
} }
err = catalog.SubmitListen(ctx, store, opts) err = catalog.SubmitListen(ctx, store, opts)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to import spotify playback item") l.Err(err).Msg("Failed to import spotify playback item")
return err return fmt.Errorf("ImportSpotifyFile: %w", err)
} }
throttleFunc() throttleFunc()
} }

@ -3,6 +3,7 @@ package mbz
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"slices" "slices"
"github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/logger"
@ -28,7 +29,7 @@ func (c *MusicBrainzClient) getArtist(ctx context.Context, id uuid.UUID) (*Music
mbzArtist := new(MusicBrainzArtist) mbzArtist := new(MusicBrainzArtist)
err := c.getEntity(ctx, artistAliasFmtStr, id, mbzArtist) err := c.getEntity(ctx, artistAliasFmtStr, id, mbzArtist)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("getArtist: %w", err)
} }
return mbzArtist, nil return mbzArtist, nil
} }
@ -38,10 +39,10 @@ func (c *MusicBrainzClient) GetArtistPrimaryAliases(ctx context.Context, id uuid
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
artist, err := c.getArtist(ctx, id) artist, err := c.getArtist(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetArtistPrimaryAliases: %w", err)
} }
if artist == nil { if artist == nil {
return nil, errors.New("artist could not be found by musicbrainz") return nil, errors.New("GetArtistPrimaryAliases: artist could not be found by musicbrainz")
} }
used := make(map[string]bool) used := make(map[string]bool)
ret := make([]string, 1) ret := make([]string, 1)

@ -52,19 +52,19 @@ func (c *MusicBrainzClient) getEntity(ctx context.Context, fmtStr string, id uui
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to build MusicBrainz request") l.Err(err).Msg("Failed to build MusicBrainz request")
return err return fmt.Errorf("getEntity: %w", err)
} }
l.Debug().Msg("Adding MusicBrainz request to queue") l.Debug().Msg("Adding MusicBrainz request to queue")
body, err := c.queue(ctx, req) body, err := c.queue(ctx, req)
if err != nil { if err != nil {
l.Err(err).Msg("MusicBrainz request failed") l.Err(err).Msg("MusicBrainz request failed")
return err return fmt.Errorf("getEntity: %w", err)
} }
err = json.Unmarshal(body, result) err = json.Unmarshal(body, result)
if err != nil { if err != nil {
l.Err(err).Str("body", string(body)).Msg("Failed to unmarshal MusicBrainz response body") l.Err(err).Str("body", string(body)).Msg("Failed to unmarshal MusicBrainz response body")
return err return fmt.Errorf("getEntity: %w", err)
} }
return nil return nil

@ -2,6 +2,7 @@ package mbz
import ( import (
"context" "context"
"fmt"
"slices" "slices"
"github.com/google/uuid" "github.com/google/uuid"
@ -36,7 +37,7 @@ func (c *MusicBrainzClient) GetReleaseGroup(ctx context.Context, id uuid.UUID) (
mbzRG := new(MusicBrainzReleaseGroup) mbzRG := new(MusicBrainzReleaseGroup)
err := c.getEntity(ctx, releaseGroupFmtStr, id, mbzRG) err := c.getEntity(ctx, releaseGroupFmtStr, id, mbzRG)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetReleaseGroup: %w", err)
} }
return mbzRG, nil return mbzRG, nil
} }
@ -45,7 +46,7 @@ func (c *MusicBrainzClient) GetRelease(ctx context.Context, id uuid.UUID) (*Musi
mbzRelease := new(MusicBrainzRelease) mbzRelease := new(MusicBrainzRelease)
err := c.getEntity(ctx, releaseFmtStr, id, mbzRelease) err := c.getEntity(ctx, releaseFmtStr, id, mbzRelease)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetRelease: %w", err)
} }
return mbzRelease, nil return mbzRelease, nil
} }
@ -53,7 +54,7 @@ func (c *MusicBrainzClient) GetRelease(ctx context.Context, id uuid.UUID) (*Musi
func (c *MusicBrainzClient) GetReleaseTitles(ctx context.Context, RGID uuid.UUID) ([]string, error) { func (c *MusicBrainzClient) GetReleaseTitles(ctx context.Context, RGID uuid.UUID) ([]string, error) {
releaseGroup, err := c.GetReleaseGroup(ctx, RGID) releaseGroup, err := c.GetReleaseGroup(ctx, RGID)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetReleaseTitles: %w", err)
} }
var titles []string var titles []string
@ -80,7 +81,7 @@ func ReleaseGroupToTitles(rg *MusicBrainzReleaseGroup) []string {
func (c *MusicBrainzClient) GetLatinTitles(ctx context.Context, id uuid.UUID) ([]string, error) { func (c *MusicBrainzClient) GetLatinTitles(ctx context.Context, id uuid.UUID) ([]string, error) {
rg, err := c.GetReleaseGroup(ctx, id) rg, err := c.GetReleaseGroup(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetLatinTitles: %w", err)
} }
titles := make([]string, 0) titles := make([]string, 0)
for _, r := range rg.Releases { for _, r := range rg.Releases {

@ -2,6 +2,7 @@ package mbz
import ( import (
"context" "context"
"fmt"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -17,7 +18,7 @@ func (c *MusicBrainzClient) GetTrack(ctx context.Context, id uuid.UUID) (*MusicB
track := new(MusicBrainzTrack) track := new(MusicBrainzTrack)
err := c.getEntity(ctx, recordingFmtStr, id, track) err := c.getEntity(ctx, recordingFmtStr, id, track)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("GetTrack: %w", err)
} }
return track, nil return track, nil
} }

@ -10,6 +10,7 @@ type Artist struct {
Image *uuid.UUID `json:"image"` Image *uuid.UUID `json:"image"`
ListenCount int64 `json:"listen_count"` ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"` TimeListened int64 `json:"time_listened"`
IsPrimary bool `json:"is_primary,omitempty"`
} }
type SimpleArtist struct { type SimpleArtist struct {

@ -199,28 +199,39 @@ func (q *Queries) GetArtistByName(ctx context.Context, alias string) (GetArtistB
const getReleaseArtists = `-- name: GetReleaseArtists :many const getReleaseArtists = `-- name: GetReleaseArtists :many
SELECT SELECT
a.id, a.musicbrainz_id, a.image, a.image_source, a.name a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
ar.is_primary as is_primary
FROM artists_with_name a FROM artists_with_name a
LEFT JOIN artist_releases ar ON a.id = ar.artist_id LEFT JOIN artist_releases ar ON a.id = ar.artist_id
WHERE ar.release_id = $1 WHERE ar.release_id = $1
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, ar.is_primary
` `
func (q *Queries) GetReleaseArtists(ctx context.Context, releaseID int32) ([]ArtistsWithName, error) { type GetReleaseArtistsRow struct {
ID int32
MusicBrainzID *uuid.UUID
Image *uuid.UUID
ImageSource pgtype.Text
Name string
IsPrimary pgtype.Bool
}
func (q *Queries) GetReleaseArtists(ctx context.Context, releaseID int32) ([]GetReleaseArtistsRow, error) {
rows, err := q.db.Query(ctx, getReleaseArtists, releaseID) rows, err := q.db.Query(ctx, getReleaseArtists, releaseID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []ArtistsWithName var items []GetReleaseArtistsRow
for rows.Next() { for rows.Next() {
var i ArtistsWithName var i GetReleaseArtistsRow
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.MusicBrainzID, &i.MusicBrainzID,
&i.Image, &i.Image,
&i.ImageSource, &i.ImageSource,
&i.Name, &i.Name,
&i.IsPrimary,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -297,28 +308,39 @@ func (q *Queries) GetTopArtistsPaginated(ctx context.Context, arg GetTopArtistsP
const getTrackArtists = `-- name: GetTrackArtists :many const getTrackArtists = `-- name: GetTrackArtists :many
SELECT SELECT
a.id, a.musicbrainz_id, a.image, a.image_source, a.name a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
at.is_primary as is_primary
FROM artists_with_name a FROM artists_with_name a
LEFT JOIN artist_tracks at ON a.id = at.artist_id LEFT JOIN artist_tracks at ON a.id = at.artist_id
WHERE at.track_id = $1 WHERE at.track_id = $1
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, at.is_primary
` `
func (q *Queries) GetTrackArtists(ctx context.Context, trackID int32) ([]ArtistsWithName, error) { type GetTrackArtistsRow struct {
ID int32
MusicBrainzID *uuid.UUID
Image *uuid.UUID
ImageSource pgtype.Text
Name string
IsPrimary pgtype.Bool
}
func (q *Queries) GetTrackArtists(ctx context.Context, trackID int32) ([]GetTrackArtistsRow, error) {
rows, err := q.db.Query(ctx, getTrackArtists, trackID) rows, err := q.db.Query(ctx, getTrackArtists, trackID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []ArtistsWithName var items []GetTrackArtistsRow
for rows.Next() { for rows.Next() {
var i ArtistsWithName var i GetTrackArtistsRow
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.MusicBrainzID, &i.MusicBrainzID,
&i.Image, &i.Image,
&i.ImageSource, &i.ImageSource,
&i.Name, &i.Name,
&i.IsPrimary,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

@ -194,12 +194,7 @@ SELECT
l.track_id, l.listened_at, l.client, l.user_id, l.track_id, l.listened_at, l.client, l.user_id,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
JOIN artist_tracks at ON t.id = at.track_id JOIN artist_tracks at ON t.id = at.track_id
@ -266,12 +261,7 @@ SELECT
l.track_id, l.listened_at, l.client, l.user_id, l.track_id, l.listened_at, l.client, l.user_id,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2 WHERE l.listened_at BETWEEN $1 AND $2
@ -337,12 +327,7 @@ SELECT
l.track_id, l.listened_at, l.client, l.user_id, l.track_id, l.listened_at, l.client, l.user_id,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2 WHERE l.listened_at BETWEEN $1 AND $2
@ -408,12 +393,7 @@ SELECT
l.track_id, l.listened_at, l.client, l.user_id, l.track_id, l.listened_at, l.client, l.user_id,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2 WHERE l.listened_at BETWEEN $1 AND $2

@ -80,11 +80,13 @@ type ArtistAlias struct {
type ArtistRelease struct { type ArtistRelease struct {
ArtistID int32 ArtistID int32
ReleaseID int32 ReleaseID int32
IsPrimary bool
} }
type ArtistTrack struct { type ArtistTrack struct {
ArtistID int32 ArtistID int32
TrackID int32 TrackID int32
IsPrimary bool
} }
type ArtistsWithName struct { type ArtistsWithName struct {

@ -197,12 +197,7 @@ func (q *Queries) GetReleaseByMbzID(ctx context.Context, musicbrainzID *uuid.UUI
const getReleasesWithoutImages = `-- name: GetReleasesWithoutImages :many const getReleasesWithoutImages = `-- name: GetReleasesWithoutImages :many
SELECT SELECT
r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title, r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title,
( get_artists_for_release(r.id) AS artists
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
FROM artists_with_name a
JOIN artist_releases ar ON a.id = ar.artist_id
WHERE ar.release_id = r.id
) AS artists
FROM releases_with_title r FROM releases_with_title r
WHERE r.image IS NULL WHERE r.image IS NULL
AND r.id > $2 AND r.id > $2
@ -257,12 +252,7 @@ const getTopReleasesFromArtist = `-- name: GetTopReleasesFromArtist :many
SELECT SELECT
r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title, r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title,
COUNT(*) AS listen_count, COUNT(*) AS listen_count,
( get_artists_for_release(r.id) AS artists
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
FROM artists_with_name a
JOIN artist_releases ar ON ar.artist_id = a.id
WHERE ar.release_id = r.id
) AS artists
FROM listens l FROM listens l
JOIN tracks t ON l.track_id = t.id JOIN tracks t ON l.track_id = t.id
JOIN releases_with_title r ON t.release_id = r.id JOIN releases_with_title r ON t.release_id = r.id
@ -332,12 +322,7 @@ const getTopReleasesPaginated = `-- name: GetTopReleasesPaginated :many
SELECT SELECT
r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title, r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title,
COUNT(*) AS listen_count, COUNT(*) AS listen_count,
( get_artists_for_release(r.id) AS artists
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
FROM artists_with_name a
JOIN artist_releases ar ON ar.artist_id = a.id
WHERE ar.release_id = r.id
) AS artists
FROM listens l FROM listens l
JOIN tracks t ON l.track_id = t.id JOIN tracks t ON l.track_id = t.id
JOIN releases_with_title r ON t.release_id = r.id JOIN releases_with_title r ON t.release_id = r.id
@ -461,6 +446,22 @@ func (q *Queries) UpdateReleaseMbzID(ctx context.Context, arg UpdateReleaseMbzID
return err return err
} }
const updateReleasePrimaryArtist = `-- name: UpdateReleasePrimaryArtist :exec
UPDATE artist_releases SET is_primary = $3
WHERE artist_id = $1 AND release_id = $2
`
type UpdateReleasePrimaryArtistParams struct {
ArtistID int32
ReleaseID int32
IsPrimary bool
}
func (q *Queries) UpdateReleasePrimaryArtist(ctx context.Context, arg UpdateReleasePrimaryArtistParams) error {
_, err := q.db.Exec(ctx, updateReleasePrimaryArtist, arg.ArtistID, arg.ReleaseID, arg.IsPrimary)
return err
}
const updateReleaseVariousArtists = `-- name: UpdateReleaseVariousArtists :exec const updateReleaseVariousArtists = `-- name: UpdateReleaseVariousArtists :exec
UPDATE releases SET various_artists = $2 UPDATE releases SET various_artists = $2
WHERE id = $1 WHERE id = $1

@ -136,12 +136,7 @@ SELECT
ranked.image, ranked.image,
ranked.various_artists, ranked.various_artists,
ranked.score, ranked.score,
( get_artists_for_release(ranked.id) AS artists
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
FROM artists_with_name a
JOIN artist_releases ar ON ar.artist_id = a.id
WHERE ar.release_id = ranked.id
) AS artists
FROM ( FROM (
SELECT SELECT
r.id, r.id,
@ -211,12 +206,7 @@ SELECT
ranked.image, ranked.image,
ranked.various_artists, ranked.various_artists,
ranked.score, ranked.score,
( get_artists_for_release(ranked.id) AS artists
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
FROM artists_with_name a
JOIN artist_releases ar ON ar.artist_id = a.id
WHERE ar.release_id = ranked.id
) AS artists
FROM ( FROM (
SELECT SELECT
r.id, r.id,
@ -286,12 +276,7 @@ SELECT
ranked.release_id, ranked.release_id,
ranked.image, ranked.image,
ranked.score, ranked.score,
( get_artists_for_track(ranked.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = ranked.id
) AS artists
FROM ( FROM (
SELECT SELECT
t.id, t.id,
@ -362,12 +347,7 @@ SELECT
ranked.release_id, ranked.release_id,
ranked.image, ranked.image,
ranked.score, ranked.score,
( get_artists_for_track(ranked.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = ranked.id
) AS artists
FROM ( FROM (
SELECT SELECT
t.id, t.id,

@ -138,12 +138,7 @@ SELECT
t.release_id, t.release_id,
r.image, r.image,
COUNT(*) AS listen_count, COUNT(*) AS listen_count,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at2
JOIN artists_with_name a ON a.id = at2.artist_id
WHERE at2.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
JOIN releases r ON t.release_id = r.id JOIN releases r ON t.release_id = r.id
@ -215,12 +210,7 @@ SELECT
t.release_id, t.release_id,
r.image, r.image,
COUNT(*) AS listen_count, COUNT(*) AS listen_count,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at2
JOIN artists_with_name a ON a.id = at2.artist_id
WHERE at2.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
JOIN releases r ON t.release_id = r.id JOIN releases r ON t.release_id = r.id
@ -291,12 +281,7 @@ SELECT
t.release_id, t.release_id,
r.image, r.image,
COUNT(*) AS listen_count, COUNT(*) AS listen_count,
( get_artists_for_track(t.id) AS artists
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
FROM artist_tracks at
JOIN artists_with_name a ON a.id = at.artist_id
WHERE at.track_id = t.id
) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id JOIN tracks_with_title t ON l.track_id = t.id
JOIN releases r ON t.release_id = r.id JOIN releases r ON t.release_id = r.id
@ -502,3 +487,19 @@ func (q *Queries) UpdateTrackMbzID(ctx context.Context, arg UpdateTrackMbzIDPara
_, err := q.db.Exec(ctx, updateTrackMbzID, arg.ID, arg.MusicBrainzID) _, err := q.db.Exec(ctx, updateTrackMbzID, arg.ID, arg.MusicBrainzID)
return err return err
} }
const updateTrackPrimaryArtist = `-- name: UpdateTrackPrimaryArtist :exec
UPDATE artist_tracks SET is_primary = $3
WHERE artist_id = $1 AND track_id = $2
`
type UpdateTrackPrimaryArtistParams struct {
ArtistID int32
TrackID int32
IsPrimary bool
}
func (q *Queries) UpdateTrackPrimaryArtist(ctx context.Context, arg UpdateTrackPrimaryArtistParams) error {
_, err := q.db.Exec(ctx, updateTrackPrimaryArtist, arg.ArtistID, arg.TrackID, arg.IsPrimary)
return err
}

@ -90,22 +90,22 @@ func DateRange(week, month, year int) (time.Time, time.Time, error) {
} }
if month != 0 && (month < 1 || month > 12) { if month != 0 && (month < 1 || month > 12) {
return time.Time{}, time.Time{}, errors.New("invalid month") return time.Time{}, time.Time{}, errors.New("DateRange: invalid month")
} }
if week != 0 && (week < 1 || week > 53) { if week != 0 && (week < 1 || week > 53) {
return time.Time{}, time.Time{}, errors.New("invalid week") return time.Time{}, time.Time{}, errors.New("DateRange: invalid week")
} }
if year < 1 { if year < 1 {
return time.Time{}, time.Time{}, errors.New("invalid year") return time.Time{}, time.Time{}, errors.New("DateRange: invalid year")
} }
loc := time.Local loc := time.Local
if week != 0 { if week != 0 {
if month != 0 { if month != 0 {
return time.Time{}, time.Time{}, errors.New("cannot specify both week and month") return time.Time{}, time.Time{}, errors.New("DateRange: cannot specify both week and month")
} }
// Specific week // Specific week
start := time.Date(year, 1, 1, 0, 0, 0, 0, loc) start := time.Date(year, 1, 1, 0, 0, 0, 0, loc)
@ -133,31 +133,34 @@ func DateRange(week, month, year int) (time.Time, time.Time, error) {
func CopyFile(src, dst string) (err error) { func CopyFile(src, dst string) (err error) {
sfi, err := os.Stat(src) sfi, err := os.Stat(src)
if err != nil { if err != nil {
return return fmt.Errorf("CopyFile: %w", err)
} }
if !sfi.Mode().IsRegular() { if !sfi.Mode().IsRegular() {
// cannot copy non-regular files (e.g., directories, // cannot copy non-regular files (e.g., directories,
// symlinks, devices, etc.) // symlinks, devices, etc.)
return fmt.Errorf("non-regular source file %s (%q)", sfi.Name(), sfi.Mode().String()) return fmt.Errorf("CopyFile: non-regular source file %s (%q)", sfi.Name(), sfi.Mode().String())
} }
dfi, err := os.Stat(dst) dfi, err := os.Stat(dst)
if err != nil { if err != nil {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
return return fmt.Errorf("CopyFile: %w", err)
} }
} else { } else {
if !(dfi.Mode().IsRegular()) { if !(dfi.Mode().IsRegular()) {
return fmt.Errorf("non-regular destination file %s (%q)", dfi.Name(), dfi.Mode().String()) return fmt.Errorf("CopyFile: non-regular destination file %s (%q)", dfi.Name(), dfi.Mode().String())
} }
if os.SameFile(sfi, dfi) { if os.SameFile(sfi, dfi) {
return return fmt.Errorf("CopyFile: %w", err)
} }
} }
if err = os.Link(src, dst); err == nil { if err = os.Link(src, dst); err == nil {
return return fmt.Errorf("CopyFile: %w", err)
} }
err = copyFileContents(src, dst) err = copyFileContents(src, dst)
return if err != nil {
return fmt.Errorf("CopyFile: %w", err)
}
return nil
} }
// copyFileContents copies the contents of the file named src to the file named // copyFileContents copies the contents of the file named src to the file named
@ -167,24 +170,22 @@ func CopyFile(src, dst string) (err error) {
func copyFileContents(src, dst string) (err error) { func copyFileContents(src, dst string) (err error) {
in, err := os.Open(src) in, err := os.Open(src)
if err != nil { if err != nil {
return return fmt.Errorf("copyFileContents: %w", err)
} }
defer in.Close() defer in.Close()
out, err := os.Create(dst) out, err := os.Create(dst)
if err != nil { if err != nil {
return return fmt.Errorf("copyFileContents: %w", err)
} }
defer func() { defer out.Close()
cerr := out.Close()
if err == nil {
err = cerr
}
}()
if _, err = io.Copy(out, in); err != nil { if _, err = io.Copy(out, in); err != nil {
return return fmt.Errorf("copyFileContents: %w", err)
} }
err = out.Sync() err = out.Sync()
return if err != nil {
return fmt.Errorf("copyFileContents: %w", err)
}
return nil
} }
// Returns the same slice, but with all strings that are equal (with strings.EqualFold) // Returns the same slice, but with all strings that are equal (with strings.EqualFold)
@ -281,7 +282,7 @@ func GenerateRandomString(length int) (string, error) {
for i := range length { for i := range length {
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("GenerateRandomString: %w", err)
} }
ret[i] = letters[num.Int64()] ret[i] = letters[num.Int64()]
} }
@ -311,3 +312,18 @@ func MoreThanOneString(s ...string) bool {
} }
return count > 1 return count > 1
} }
func ParseBool(s string) (value, ok bool) {
if strings.ToLower(s) == "true" {
value = true
ok = true
return
} else if strings.ToLower(s) == "false" {
value = false
ok = true
return
} else {
ok = false
return
}
}

Loading…
Cancel
Save