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
- Login form now correctly handles special characters
- Update User form now correctly handles special characters
- Delete Listen button is now hidden when not logged in
- Merge selections now function correctly when selecting an item while another is selected
- Use anchor tags for top tracks and top albums
- 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
musicbrainz_id: string
time_listened: number
is_primary: boolean
}
type Album = {
id: number,

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

@ -35,92 +35,52 @@ export default function TopItemList<T extends Item>({ data, separators, type, cl
function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) {
const itemClasses = `flex items-center gap-2 hover:text-(--color-fg-secondary)`
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();
}
const itemClasses = `flex items-center gap-2`
switch (type) {
case "album": {
const album = item as Album;
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
handleItemClick("album", album.id);
}
};
return (
<div style={{fontSize: 12}}>
<div
className={itemClasses}
onClick={() => handleItemClick("album", album.id)}
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 style={{fontSize: 12}} className={itemClasses}>
<Link to={`/album/${album.id}`}>
<img loading="lazy" src={imageUrl(album.image, "small")} alt={album.title} className="min-w-[48px]" />
</Link>
<div>
<Link to={`/album/${album.id}`} className="hover:text-(--color-fg-secondary)">
<span style={{fontSize: 14}}>{album.title}</span>
<br />
{album.is_various_artists ?
<span className="color-fg-secondary">Various Artists</span>
:
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
<ArtistLinks artists={album.artists ? [album.artists[0]] : [{id: 0, name: 'Unknown Artist'}]}/>
</div>
}
<div className="color-fg-secondary">{album.listen_count} plays</div>
</Link>
<br />
{album.is_various_artists ?
<span className="color-fg-secondary">Various Artists</span>
:
<div>
<ArtistLinks artists={album.artists ? [album.artists[0]] : [{id: 0, name: 'Unknown Artist'}]}/>
</div>
}
<div className="color-fg-secondary">{album.listen_count} plays</div>
</div>
</div>
);
}
case "track": {
const track = item as Track;
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
handleItemClick("track", track.id);
}
};
return (
<div style={{fontSize: 12}}>
<div
className={itemClasses}
onClick={() => handleItemClick("track", track.id)}
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 style={{fontSize: 12}} className={itemClasses}>
<Link to={`/track/${track.id}`}>
<img loading="lazy" src={imageUrl(track.image, "small")} alt={track.title} className="min-w-[48px]" />
</Link>
<div>
<span style={{fontSize: 14}}>{track.title}</span>
<Link to={`/track/${track.id}`} className="hover:text-(--color-fg-secondary)">
<span style={{fontSize: 14}}>{track.title}</span>
</Link>
<br />
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
<div>
<ArtistLinks artists={track.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
</div>
<div className="color-fg-secondary">{track.listen_count} plays</div>
</div>
</div>
</div>
);
}
@ -128,12 +88,12 @@ function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artis
const artist = item as Artist;
return (
<div style={{fontSize: 12}}>
<Link className={itemClasses+' mt-1 mb-[6px]'} to={`/artist/${artist.id}`}>
<img src={imageUrl(artist.image, "small")} alt={artist.name} />
<div>
<span style={{fontSize: 14}}>{artist.name}</span>
<div className="color-fg-secondary">{artist.listen_count} plays</div>
</div>
<Link className={itemClasses+' mt-1 mb-[6px] hover:text-(--color-fg-secondary)'} to={`/artist/${artist.id}`}>
<img loading="lazy" src={imageUrl(artist.image, "small")} alt={artist.name} className="min-w-[48px]" />
<div>
<span style={{fontSize: 14}}>{artist.name}</span>
<div className="color-fg-secondary">{artist.listen_count} plays</div>
</div>
</Link>
</div>
);

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

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

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

@ -7,8 +7,8 @@ import { useAppContext } from "~/providers/AppProvider";
import MergeModal from "~/components/modals/MergeModal";
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
import DeleteModal from "~/components/modals/DeleteModal";
import RenameModal from "~/components/modals/EditModal";
import EditModal from "~/components/modals/EditModal";
import RenameModal from "~/components/modals/EditModal/EditModal";
import EditModal from "~/components/modals/EditModal/EditModal";
export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise<Response>
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
@ -69,9 +69,9 @@ export default function MediaLayout(props: Props) {
content={title}
/>
<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">
<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 className="flex flex-col items-start">
<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
SELECT
a.*
a.*,
at.is_primary as is_primary
FROM artists_with_name a
LEFT JOIN artist_tracks at ON a.id = at.artist_id
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
SELECT * FROM artists WHERE image = $1 LIMIT 1;
-- name: GetReleaseArtists :many
SELECT
a.*
a.*,
ar.is_primary as is_primary
FROM artists_with_name a
LEFT JOIN artist_releases ar ON a.id = ar.artist_id
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
WITH artist_with_aliases AS (

@ -8,12 +8,7 @@ SELECT
l.*,
t.title AS track_title,
t.release_id AS release_id,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2
@ -25,12 +20,7 @@ SELECT
l.*,
t.title AS track_title,
t.release_id AS release_id,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
JOIN artist_tracks at ON t.id = at.track_id
@ -44,12 +34,7 @@ SELECT
l.*,
t.title AS track_title,
t.release_id AS release_id,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2
@ -62,12 +47,7 @@ SELECT
l.*,
t.title AS track_title,
t.release_id AS release_id,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2

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

@ -42,12 +42,7 @@ SELECT
ranked.release_id,
ranked.image,
ranked.score,
(
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
get_artists_for_track(ranked.id) AS artists
FROM (
SELECT
t.id,
@ -74,12 +69,7 @@ SELECT
ranked.release_id,
ranked.image,
ranked.score,
(
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
get_artists_for_track(ranked.id) AS artists
FROM (
SELECT
t.id,
@ -106,12 +96,7 @@ SELECT
ranked.image,
ranked.various_artists,
ranked.score,
(
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
get_artists_for_release(ranked.id) AS artists
FROM (
SELECT
r.id,
@ -137,12 +122,7 @@ SELECT
ranked.image,
ranked.various_artists,
ranked.score,
(
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
get_artists_for_release(ranked.id) AS artists
FROM (
SELECT
r.id,

@ -43,12 +43,7 @@ SELECT
t.release_id,
r.image,
COUNT(*) AS listen_count,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
JOIN releases r ON t.release_id = r.id
@ -65,12 +60,7 @@ SELECT
t.release_id,
r.image,
COUNT(*) AS listen_count,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
JOIN releases r ON t.release_id = r.id
@ -89,12 +79,7 @@ SELECT
t.release_id,
r.image,
COUNT(*) AS listen_count,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
JOIN releases r ON t.release_id = r.id
@ -135,5 +120,9 @@ WHERE id = $1;
UPDATE tracks SET release_id = $2
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
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.
##### KOITO_IMPORT_AFTER_UNIX
- 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
- 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.

@ -1,7 +1,6 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
@ -40,44 +39,43 @@ func GetAliasesHandler(store db.DB) http.HandlerFunc {
if artistIDStr != "" {
artistID, err := strconv.Atoi(artistIDStr)
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)
return
}
aliases, err = store.GetAllArtistAliases(ctx, int32(artistID))
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)
return
}
} else if albumIDStr != "" {
albumID, err := strconv.Atoi(albumIDStr)
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)
return
}
aliases, err = store.GetAllAlbumAliases(ctx, int32(albumID))
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)
return
}
} else if trackIDStr != "" {
trackID, err := strconv.Atoi(trackIDStr)
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)
return
}
aliases, err = store.GetAllTrackAliases(ctx, int32(trackID))
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)
return
}
}
utils.WriteJSON(w, http.StatusOK, aliases)
}
}
@ -88,7 +86,7 @@ func DeleteAliasHandler(store db.DB) http.HandlerFunc {
ctx := r.Context()
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
artistIDStr := r.URL.Query().Get("artist_id")
@ -97,52 +95,56 @@ func DeleteAliasHandler(store db.DB) http.HandlerFunc {
alias := r.URL.Query().Get("alias")
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)
return
}
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)
return
}
var err error
if artistIDStr != "" {
artistID, err := strconv.Atoi(artistIDStr)
var artistID int
artistID, err = strconv.Atoi(artistIDStr)
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)
return
}
err = store.DeleteArtistAlias(ctx, int32(artistID), alias)
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)
return
}
} else if albumIDStr != "" {
albumID, err := strconv.Atoi(albumIDStr)
var albumID int
albumID, err = strconv.Atoi(albumIDStr)
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)
return
}
err = store.DeleteAlbumAlias(ctx, int32(albumID), alias)
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)
return
}
} else if trackIDStr != "" {
trackID, err := strconv.Atoi(trackIDStr)
var trackID int
trackID, err = strconv.Atoi(trackIDStr)
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)
return
}
err = store.DeleteTrackAlias(ctx, int32(trackID), alias)
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)
return
}
@ -158,16 +160,18 @@ func CreateAliasHandler(store db.DB) http.HandlerFunc {
ctx := r.Context()
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()
if err != nil {
l.Debug().AnErr("error", err).Msg("CreateAliasHandler: Failed to parse form")
utils.WriteError(w, "invalid request body", http.StatusBadRequest)
return
}
alias := r.FormValue("alias")
if alias == "" {
l.Debug().Msg("CreateAliasHandler: Alias parameter missing")
utils.WriteError(w, "alias must be provided", http.StatusBadRequest)
return
}
@ -176,53 +180,54 @@ func CreateAliasHandler(store db.DB) http.HandlerFunc {
albumIDStr := r.URL.Query().Get("album_id")
trackIDStr := r.URL.Query().Get("track_id")
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
l.Debug().Msgf("CreateAliasHandler: Request is missing required parameters")
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" {
l.Debug().Msg("CreateAliasHandler: Missing ID parameter")
utils.WriteError(w, "artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
return
}
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
l.Debug().Msgf("CreateAliasHandler: Request is has more than one of artist_id, album_id, and track_id")
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
l.Debug().Msg("CreateAliasHandler: Multiple ID parameters provided")
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided", http.StatusBadRequest)
return
}
var id int
if artistIDStr != "" {
artistID, err := strconv.Atoi(artistIDStr)
id, err = strconv.Atoi(artistIDStr)
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)
return
}
err = store.SaveArtistAliases(ctx, int32(artistID), []string{alias}, "Manual")
err = store.SaveArtistAliases(ctx, int32(id), []string{alias}, "Manual")
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)
return
}
} else if albumIDStr != "" {
albumID, err := strconv.Atoi(albumIDStr)
id, err = strconv.Atoi(albumIDStr)
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)
return
}
err = store.SaveAlbumAliases(ctx, int32(albumID), []string{alias}, "Manual")
err = store.SaveAlbumAliases(ctx, int32(id), []string{alias}, "Manual")
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)
return
}
} else if trackIDStr != "" {
trackID, err := strconv.Atoi(trackIDStr)
id, err = strconv.Atoi(trackIDStr)
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)
return
}
err = store.SaveTrackAliases(ctx, int32(trackID), []string{alias}, "Manual")
err = store.SaveTrackAliases(ctx, int32(id), []string{alias}, "Manual")
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)
return
}
@ -238,7 +243,7 @@ func SetPrimaryAliasHandler(store db.DB) http.HandlerFunc {
ctx := r.Context()
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
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")
alias := r.URL.Query().Get("alias")
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
l.Debug().Msgf("SetPrimaryAliasHandler: Request is missing required parameters")
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
if alias == "" {
l.Debug().Msg("SetPrimaryAliasHandler: Missing alias parameter")
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
}
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
l.Debug().Msgf("SetPrimaryAliasHandler: Request is has more than one of artist_id, album_id, and track_id")
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
l.Debug().Msg("SetPrimaryAliasHandler: Multiple ID parameters provided")
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided", http.StatusBadRequest)
return
}
var id int
var err error
if artistIDStr != "" {
artistID, err := strconv.Atoi(artistIDStr)
id, err = strconv.Atoi(artistIDStr)
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)
return
}
err = store.SetPrimaryArtistAlias(ctx, int32(artistID), alias)
err = store.SetPrimaryArtistAlias(ctx, int32(id), alias)
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)
return
}
} else if albumIDStr != "" {
albumID, err := strconv.Atoi(albumIDStr)
id, err = strconv.Atoi(albumIDStr)
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)
return
}
err = store.SetPrimaryAlbumAlias(ctx, int32(albumID), alias)
err = store.SetPrimaryAlbumAlias(ctx, int32(id), alias)
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)
return
}
} else if trackIDStr != "" {
trackID, err := strconv.Atoi(trackIDStr)
id, err = strconv.Atoi(trackIDStr)
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)
return
}
err = store.SetPrimaryTrackAlias(ctx, int32(trackID), alias)
err = store.SetPrimaryTrackAlias(ctx, int32(id), alias)
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)
return
}

@ -1,7 +1,6 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
@ -16,45 +15,47 @@ func GenerateApiKeyHandler(store db.DB) http.HandlerFunc {
ctx := r.Context()
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)
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)
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")
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)
return
}
apiKey, err := utils.GenerateRandomString(48)
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)
return
}
opts := db.SaveApiKeyOpts{
key, err := store.SaveApiKey(ctx, db.SaveApiKeyOpts{
UserID: user.ID,
Key: apiKey,
Label: label,
}
l.Debug().Msgf("GenerateApiKeyHandler: Saving API key with options: %+v", opts)
key, err := store.SaveApiKey(ctx, opts)
})
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)
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)
}
}
@ -64,39 +65,36 @@ func DeleteApiKeyHandler(store db.DB) http.HandlerFunc {
ctx := r.Context()
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)
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)
return
}
idStr := r.URL.Query().Get("id")
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)
return
}
apiKey, err := strconv.Atoi(idStr)
apiKeyID, err := strconv.Atoi(idStr)
if err != nil {
l.Debug().AnErr("error", fmt.Errorf("DeleteApiKeyHandler: %w", err)).Msg("Invalid API key ID")
utils.WriteError(w, "id is invalid", http.StatusBadRequest)
l.Debug().AnErr("error", err).Msg("DeleteApiKeyHandler: Invalid API key ID")
utils.WriteError(w, "invalid id", http.StatusBadRequest)
return
}
l.Debug().Msgf("DeleteApiKeyHandler: Deleting API key with ID: %d", apiKey)
err = store.DeleteApiKey(ctx, int32(apiKey))
if err != nil {
l.Err(fmt.Errorf("DeleteApiKeyHandler: %w", err)).Msg("Failed to delete API key")
if err := store.DeleteApiKey(ctx, int32(apiKeyID)); err != nil {
l.Error().Err(err).Msg("DeleteApiKeyHandler: Failed to delete API key")
utils.WriteError(w, "failed to delete api key", http.StatusInternalServerError)
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)
}
}
@ -106,25 +104,23 @@ func GetApiKeysHandler(store db.DB) http.HandlerFunc {
ctx := r.Context()
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)
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)
return
}
l.Debug().Msgf("GetApiKeysHandler: Retrieving API keys for user ID: %d", user.ID)
apiKeys, err := store.GetApiKeysByUserID(ctx, user.ID)
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)
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)
}
}
@ -134,45 +130,42 @@ func UpdateApiKeyLabelHandler(store db.DB) http.HandlerFunc {
ctx := r.Context()
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)
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)
return
}
idStr := r.URL.Query().Get("id")
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)
return
}
apiKeyID, err := strconv.Atoi(idStr)
if err != nil {
l.Debug().AnErr("error", fmt.Errorf("UpdateApiKeyLabelHandler: %w", err)).Msg("Invalid API key ID")
utils.WriteError(w, "id is invalid", http.StatusBadRequest)
l.Debug().AnErr("error", err).Msg("UpdateApiKeyLabelHandler: Invalid API key ID")
utils.WriteError(w, "invalid id", http.StatusBadRequest)
return
}
label := r.FormValue("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)
return
}
l.Debug().Msgf("UpdateApiKeyLabelHandler: Updating label for API key ID %d", apiKeyID)
err = store.UpdateApiKeyLabel(ctx, db.UpdateApiKeyLabelOpts{
if err := store.UpdateApiKeyLabel(ctx, db.UpdateApiKeyLabelOpts{
UserID: user.ID,
ID: int32(apiKeyID),
Label: label,
})
if err != nil {
l.Err(fmt.Errorf("UpdateApiKeyLabelHandler: %w", err)).Msg("Failed to update API key label")
}); err != nil {
l.Error().Err(err).Msg("UpdateApiKeyLabelHandler: Failed to update API key label")
utils.WriteError(w, "failed to update api key label", http.StatusInternalServerError)
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()
l := logger.FromContext(ctx)
l.Debug().Msg("LoginHandler: Received login request")
l.Debug().Msg("LoginHandler: Received request")
err := r.ParseForm()
if err != nil {
l.Debug().Msg("LoginHandler: Failed to parse request form")
utils.WriteError(w, "failed to parse request", http.StatusInternalServerError)
if err := r.ParseForm(); err != nil {
l.Debug().AnErr("error", err).Msg("LoginHandler: Failed to parse form")
utils.WriteError(w, "invalid request format", http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" || password == "" {
l.Debug().Msg("LoginHandler: Missing username or password")
utils.WriteError(w, "username and password are required", http.StatusBadRequest)
l.Debug().Msg("LoginHandler: Missing credentials")
utils.WriteError(w, "username and password required", http.StatusBadRequest)
return
}
l.Debug().Msgf("LoginHandler: Searching for user with username '%s'", username)
user, err := store.GetUserByUsername(ctx, username)
if err != nil {
l.Err(err).Msg("LoginHandler: Error searching for user in database")
utils.WriteError(w, "internal server error", http.StatusInternalServerError)
l.Error().Err(err).Msg("LoginHandler: Database error fetching user")
utils.WriteError(w, "authentication failed", http.StatusInternalServerError)
return
} else if user == nil {
l.Debug().Msg("LoginHandler: Username or password is incorrect")
utils.WriteError(w, "username or password is incorrect", http.StatusBadRequest)
}
if user == nil {
l.Debug().Msg("LoginHandler: User not found")
utils.WriteError(w, "invalid credentials", http.StatusUnauthorized)
return
}
err = bcrypt.CompareHashAndPassword(user.Password, []byte(password))
if err != nil {
l.Debug().Msg("LoginHandler: Password comparison failed")
utils.WriteError(w, "username or password is incorrect", http.StatusBadRequest)
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(password)); err != nil {
l.Debug().Msg("LoginHandler: Invalid password")
utils.WriteError(w, "invalid credentials", http.StatusUnauthorized)
return
}
keepSignedIn := false
expiresAt := time.Now().Add(1 * 24 * time.Hour)
expiresAt := time.Now().Add(24 * time.Hour)
if strings.ToLower(r.FormValue("remember_me")) == "true" {
keepSignedIn = true
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, keepSignedIn)
session, err := store.SaveSession(ctx, user.ID, expiresAt, r.FormValue("remember_me") == "true")
if err != nil {
l.Err(err).Msg("LoginHandler: Failed to create session")
utils.WriteError(w, "failed to create session", http.StatusInternalServerError)
l.Error().Err(err).Msg("LoginHandler: Failed to create session")
utils.WriteError(w, "authentication failed", http.StatusInternalServerError)
return
}
cookie := &http.Cookie{
http.SetCookie(w, &http.Cookie{
Name: "koito_session",
Value: session.ID.String(),
Expires: expiresAt,
Path: "/",
HttpOnly: true,
Secure: false,
}
if keepSignedIn {
cookie.Expires = expiresAt
}
})
l.Debug().Msgf("LoginHandler: Session created successfully for user ID %d", user.ID)
http.SetCookie(w, cookie)
l.Debug().Msgf("LoginHandler: User %d authenticated", user.ID)
w.WriteHeader(http.StatusNoContent)
}
}
@ -91,34 +83,27 @@ func LogoutHandler(store db.DB) http.HandlerFunc {
ctx := r.Context()
l := logger.FromContext(ctx)
l.Debug().Msg("LogoutHandler: Received logout request")
l.Debug().Msg("LogoutHandler: Received request")
cookie, err := r.Cookie("koito_session")
if err == nil {
l.Debug().Msg("LogoutHandler: Found session cookie")
sid, err := uuid.Parse(cookie.Value)
if err != nil {
l.Debug().AnErr("error", err).Msg("LogoutHandler: Invalid session cookie")
utils.WriteError(w, "session cookie is invalid", http.StatusUnauthorized)
return
}
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().AnErr("error", err).Msg("LogoutHandler: Invalid session ID")
} else if err := store.DeleteSession(ctx, sid); err != nil {
l.Error().Err(err).Msg("LogoutHandler: Failed to delete session")
}
}
l.Debug().Msg("LogoutHandler: Clearing session cookie")
http.SetCookie(w, &http.Cookie{
Name: "koito_session",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1, // expire immediately
MaxAge: -1,
})
l.Debug().Msg("LogoutHandler: Session terminated")
w.WriteHeader(http.StatusNoContent)
}
}
@ -128,16 +113,17 @@ func MeHandler(store db.DB) http.HandlerFunc {
ctx := r.Context()
l := logger.FromContext(ctx)
l.Debug().Msg("MeHandler: Received request to retrieve user information")
u := middleware.GetUserFromContext(ctx)
if u == nil {
l.Debug().Msg("MeHandler: Invalid user retrieved from context")
l.Debug().Msg("MeHandler: Received request")
user := middleware.GetUserFromContext(ctx)
if user == nil {
l.Debug().Msg("MeHandler: Unauthorized access")
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return
}
l.Debug().Msgf("MeHandler: Successfully retrieved user with ID %d", u.ID)
utils.WriteJSON(w, http.StatusOK, u)
l.Debug().Msgf("MeHandler: Returning user data for ID %d", user.ID)
utils.WriteJSON(w, http.StatusOK, user)
}
}
@ -146,41 +132,42 @@ func UpdateUserHandler(store db.DB) http.HandlerFunc {
ctx := r.Context()
l := logger.FromContext(ctx)
l.Debug().Msg("UpdateUserHandler: Received request to update user information")
u := middleware.GetUserFromContext(ctx)
if u == nil {
l.Debug().Msg("UpdateUserHandler: Unauthorized request (user context is nil)")
l.Debug().Msg("UpdateUserHandler: Received request")
user := middleware.GetUserFromContext(ctx)
if user == nil {
l.Debug().Msg("UpdateUserHandler: Unauthorized access")
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return
}
err := r.ParseForm()
if err != nil {
l.Err(err).Msg("UpdateUserHandler: Failed to parse request form")
utils.WriteError(w, "failed to parse request", http.StatusInternalServerError)
if err := r.ParseForm(); err != nil {
l.Error().Err(err).Msg("UpdateUserHandler: Invalid form data")
utils.WriteError(w, "invalid request", http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" && password == "" {
l.Debug().Msg("UpdateUserHandler: No parameters were recieved")
utils.WriteError(w, "all parameters missing", http.StatusBadRequest)
opts := db.UpdateUserOpts{ID: user.ID}
if username := r.FormValue("username"); username != "" {
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
}
l.Debug().Msgf("UpdateUserHandler: Updating user with ID %d", u.ID)
err = store.UpdateUser(ctx, db.UpdateUserOpts{
ID: u.ID,
Username: username,
Password: password,
})
if err != nil {
l.Err(err).Msg("UpdateUserHandler: Failed to update user")
utils.WriteError(w, err.Error(), http.StatusBadRequest)
if err := store.UpdateUser(ctx, opts); err != nil {
l.Error().Err(err).Msg("UpdateUserHandler: Update failed")
utils.WriteError(w, "update failed", http.StatusBadRequest)
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)
}
}

@ -10,7 +10,6 @@ import (
"github.com/gabehf/koito/internal/utils"
)
// DeleteTrackHandler deletes a track by its ID.
func DeleteTrackHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
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 {
return func(w http.ResponseWriter, r *http.Request) {
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 {
return func(w http.ResponseWriter, r *http.Request) {
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 {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

@ -117,7 +117,12 @@ func serveDefaultImage(w http.ResponseWriter, r *http.Request, size catalog.Imag
return
}
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()
} else if err != nil {
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) {
src, err := store.GetImageSource(ctx, id)
if err != nil {
return "", fmt.Errorf("downloadMissingImage: store.GetImageSource: %w", err)
return "", fmt.Errorf("downloadMissingImage: %w", err)
}
var size catalog.ImageSize
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)
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
}

@ -137,13 +137,13 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
artistMbzIDs, err := utils.ParseUUIDSlice(payload.TrackMeta.AdditionalInfo.ArtistMBIDs)
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 {
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)
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)
@ -191,7 +191,7 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
}
mbid, err := uuid.Parse(a.ArtistMBID)
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})
}

@ -53,6 +53,7 @@ func makeAuthRequest(t *testing.T, session, method, endpoint string, body io.Rea
Name: "koito_session",
Value: session,
})
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
t.Logf("Making request to %s with session: %s", endpoint, session)
return http.DefaultClient.Do(req)
}
@ -512,7 +513,7 @@ func TestAuth(t *testing.T) {
encoded = formdata.Encode()
resp, err = http.DefaultClient.Post(host()+"/apis/web/v1/login", "application/x-www-form-urlencoded", strings.NewReader(encoded))
require.NoError(t, err)
require.Equal(t, 400, resp.StatusCode)
require.Equal(t, 401, resp.StatusCode)
// 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)
@ -732,3 +733,160 @@ func TestAlbumReplaceImage(t *testing.T) {
assert.NotNil(t, 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.Get("/artist", handlers.GetArtistHandler(db))
r.Get("/artists", handlers.GetArtistsForItemHandler(db))
r.Get("/album", handlers.GetAlbumHandler(db))
r.Get("/track", handlers.GetTrackHandler(db))
r.Get("/top-tracks", handlers.GetTopTracksHandler(db))
@ -75,6 +76,7 @@ func bindRoutes(
r.Post("/merge/albums", handlers.MergeReleaseGroupsHandler(db))
r.Post("/merge/artists", handlers.MergeArtistsHandler(db))
r.Delete("/artist", handlers.DeleteArtistHandler(db))
r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db))
r.Delete("/album", handlers.DeleteAlbumHandler(db))
r.Delete("/track", handlers.DeleteTrackHandler(db))
r.Delete("/listen", handlers.DeleteListenHandler(db))

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

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

@ -3,6 +3,7 @@ package catalog
import (
"context"
"errors"
"fmt"
"github.com/gabehf/koito/internal/db"
"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) {
l := logger.FromContext(ctx)
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 {
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 {
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
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)
return track, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
return nil, fmt.Errorf("matchTrackByMbzID: %w", err)
} else {
l.Debug().Msgf("Track '%s' could not be found by MusicBrainz ID", opts.TrackName)
track, err := matchTrackByTitleAndArtist(ctx, d, opts)
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)
err = d.UpdateTrack(ctx, db.UpdateTrackOpts{
@ -65,7 +66,7 @@ func matchTrackByMbzID(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*
MusicBrainzID: opts.TrackMbzID,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("matchTrackByMbzID: %w", err)
}
track.MbzID = &opts.TrackMbzID
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)
return track, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
return nil, fmt.Errorf("matchTrackByTitleAndArtist: %w", err)
} else {
if opts.TrackMbzID != uuid.Nil {
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,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("matchTrackByTitleAndArtist: %w", err)
}
if opts.TrackMbzID == uuid.Nil {
l.Info().Msgf("Created track '%s' with title and artist", opts.TrackName)

@ -6,6 +6,7 @@ package catalog
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"time"
@ -39,6 +40,9 @@ type SubmitListenOpts struct {
// artist, release, release group, and track in DB
SkipSaveListen bool
// When true, skips caching the images and only stores the image url in the db
SkipCacheImage bool
MbzCaller mbz.MusicBrainzCaller
ArtistNames []string
Artist string
@ -51,8 +55,9 @@ type SubmitListenOpts struct {
ReleaseMbzID uuid.UUID
ReleaseGroupMbzID uuid.UUID
Time time.Time
UserID int32
Client string
UserID int32
Client string
}
const (
@ -70,16 +75,17 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
ctx,
store,
AssociateArtistsOpts{
ArtistMbzIDs: opts.ArtistMbzIDs,
ArtistNames: opts.ArtistNames,
ArtistName: opts.Artist,
ArtistMbidMap: opts.ArtistMbidMappings,
Mbzc: opts.MbzCaller,
TrackTitle: opts.TrackTitle,
ArtistMbzIDs: opts.ArtistMbzIDs,
ArtistNames: opts.ArtistNames,
ArtistName: opts.Artist,
ArtistMbidMap: opts.ArtistMbidMappings,
Mbzc: opts.MbzCaller,
TrackTitle: opts.TrackTitle,
SkipCacheImage: opts.SkipCacheImage,
})
if err != nil {
l.Error().Err(err).Msg("Failed to associate artists to listen")
return err
l.Err(err).Msg("Failed to associate artists to listen")
return fmt.Errorf("SubmitListen: %w", err)
} else if len(artists) < 1 {
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,
Mbzc: opts.MbzCaller,
Artists: artists,
SkipCacheImage: opts.SkipCacheImage,
})
if err != nil {
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")
@ -120,7 +127,7 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
})
if err != nil {
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")

@ -82,17 +82,17 @@ func SourceImageDir() string {
func ValidateImageURL(url string) error {
resp, err := http.Head(url)
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()
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")
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
@ -103,20 +103,24 @@ func DownloadAndCacheImage(ctx context.Context, id uuid.UUID, url string, size I
l := logger.FromContext(ctx)
err := ValidateImageURL(url)
if err != nil {
return err
return fmt.Errorf("DownloadAndCacheImage: %w", err)
}
l.Debug().Msgf("Downloading image for ID %s", id)
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to download image: %w", err)
return fmt.Errorf("DownloadAndCacheImage: http.Get: %w", err)
}
defer resp.Body.Close()
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.
@ -124,16 +128,24 @@ func CompressAndSaveImage(ctx context.Context, filename string, size ImageSize,
l := logger.FromContext(ctx)
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")
compressed, err := compressImage(size, body)
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
@ -144,21 +156,21 @@ func saveImage(filename string, size ImageSize, data io.Reader) error {
// Ensure the cache directory exists
err := os.MkdirAll(filepath.Join(cacheDir, string(size)), 0744)
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
imagePath := filepath.Join(cacheDir, string(size), filename)
file, err := os.Create(imagePath)
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()
// Save the image to the file
_, err = io.Copy(file, data)
if err != nil {
return fmt.Errorf("failed to save image: %w", err)
return fmt.Errorf("saveImage: failed to save image: %w", err)
}
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) {
imgBytes, err := io.ReadAll(data)
if err != nil {
return nil, err
return nil, fmt.Errorf("compressImage: io.ReadAll: %w", err)
}
px := GetImageSize(size)
// Resize with bimg
@ -180,10 +192,10 @@ func compressImage(size ImageSize, data io.Reader) (io.Reader, error) {
Type: bimg.WEBP,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("compressImage: bimg.NewImage: %w", err)
}
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
}
@ -198,19 +210,19 @@ func DeleteImage(filename uuid.UUID) error {
// }
err := os.Remove(path.Join(cacheDir, "full", filename.String()))
if err != nil && !os.IsNotExist(err) {
return err
return fmt.Errorf("DeleteImage: %w", err)
}
err = os.Remove(path.Join(cacheDir, "large", filename.String()))
if err != nil && !os.IsNotExist(err) {
return err
return fmt.Errorf("DeleteImage: %w", err)
}
err = os.Remove(path.Join(cacheDir, "medium", filename.String()))
if err != nil && !os.IsNotExist(err) {
return err
return fmt.Errorf("DeleteImage: %w", err)
}
err = os.Remove(path.Join(cacheDir, "small", filename.String()))
if err != nil && !os.IsNotExist(err) {
return err
return fmt.Errorf("DeleteImage: %w", err)
}
return nil
}
@ -230,7 +242,7 @@ func PruneOrphanedImages(ctx context.Context, store db.DB) error {
for _, dir := range []string{"large", "medium", "small", "full"} {
c, err := pruneDirImgs(ctx, store, path.Join(cacheDir, dir), memo)
if err != nil {
return err
return fmt.Errorf("PruneOrphanedImages: %w", err)
}
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)
if err != nil {
return 0, err
return 0, fmt.Errorf("pruneDirImages: %w", err)
} else if exists {
continue
}

@ -17,30 +17,31 @@ const (
const (
// BASE_URL_ENV = "KOITO_BASE_URL"
DATABASE_URL_ENV = "KOITO_DATABASE_URL"
BIND_ADDR_ENV = "KOITO_BIND_ADDR"
LISTEN_PORT_ENV = "KOITO_LISTEN_PORT"
ENABLE_STRUCTURED_LOGGING_ENV = "KOITO_ENABLE_STRUCTURED_LOGGING"
ENABLE_FULL_IMAGE_CACHE_ENV = "KOITO_ENABLE_FULL_IMAGE_CACHE"
LOG_LEVEL_ENV = "KOITO_LOG_LEVEL"
MUSICBRAINZ_URL_ENV = "KOITO_MUSICBRAINZ_URL"
MUSICBRAINZ_RATE_LIMIT_ENV = "KOITO_MUSICBRAINZ_RATE_LIMIT"
ENABLE_LBZ_RELAY_ENV = "KOITO_ENABLE_LBZ_RELAY"
LBZ_RELAY_URL_ENV = "KOITO_LBZ_RELAY_URL"
LBZ_RELAY_TOKEN_ENV = "KOITO_LBZ_RELAY_TOKEN"
CONFIG_DIR_ENV = "KOITO_CONFIG_DIR"
DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME"
DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD"
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
DISABLE_RATE_LIMIT_ENV = "KOITO_DISABLE_RATE_LIMIT"
THROTTLE_IMPORTS_MS = "KOITO_THROTTLE_IMPORTS_MS"
IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX"
IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX"
DATABASE_URL_ENV = "KOITO_DATABASE_URL"
BIND_ADDR_ENV = "KOITO_BIND_ADDR"
LISTEN_PORT_ENV = "KOITO_LISTEN_PORT"
ENABLE_STRUCTURED_LOGGING_ENV = "KOITO_ENABLE_STRUCTURED_LOGGING"
ENABLE_FULL_IMAGE_CACHE_ENV = "KOITO_ENABLE_FULL_IMAGE_CACHE"
LOG_LEVEL_ENV = "KOITO_LOG_LEVEL"
MUSICBRAINZ_URL_ENV = "KOITO_MUSICBRAINZ_URL"
MUSICBRAINZ_RATE_LIMIT_ENV = "KOITO_MUSICBRAINZ_RATE_LIMIT"
ENABLE_LBZ_RELAY_ENV = "KOITO_ENABLE_LBZ_RELAY"
LBZ_RELAY_URL_ENV = "KOITO_LBZ_RELAY_URL"
LBZ_RELAY_TOKEN_ENV = "KOITO_LBZ_RELAY_TOKEN"
CONFIG_DIR_ENV = "KOITO_CONFIG_DIR"
DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME"
DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD"
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
DISABLE_RATE_LIMIT_ENV = "KOITO_DISABLE_RATE_LIMIT"
THROTTLE_IMPORTS_MS = "KOITO_THROTTLE_IMPORTS_MS"
IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX"
IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX"
FETCH_IMAGES_DURING_IMPORT_ENV = "KOITO_FETCH_IMAGES_DURING_IMPORT"
)
type config struct {
@ -48,29 +49,30 @@ type config struct {
listenPort int
configDir string
// baseUrl string
databaseUrl string
musicBrainzUrl string
musicBrainzRateLimit int
logLevel int
structuredLogging bool
enableFullImageCache bool
lbzRelayEnabled bool
lbzRelayUrl string
lbzRelayToken string
defaultPw string
defaultUsername string
disableDeezer bool
disableCAA bool
disableMusicBrainz bool
skipImport bool
allowedHosts []string
allowAllHosts bool
allowedOrigins []string
disableRateLimit bool
importThrottleMs int
userAgent string
importBefore time.Time
importAfter time.Time
databaseUrl string
musicBrainzUrl string
musicBrainzRateLimit int
logLevel int
structuredLogging bool
enableFullImageCache bool
lbzRelayEnabled bool
lbzRelayUrl string
lbzRelayToken string
defaultPw string
defaultUsername string
disableDeezer bool
disableCAA bool
disableMusicBrainz bool
skipImport bool
fetchImageDuringImport bool
allowedHosts []string
allowAllHosts bool
allowedOrigins []string
disableRateLimit bool
importThrottleMs int
userAgent string
importBefore time.Time
importAfter time.Time
}
var (
@ -85,7 +87,10 @@ func Load(getenv func(string) string, version string) error {
once.Do(func() {
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.
@ -94,7 +99,7 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
cfg.databaseUrl = getenv(DATABASE_URL_ENV)
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)
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.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.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV))
@ -211,12 +217,6 @@ func ConfigDir() string {
return globalConfig.configDir
}
// func BaseUrl() string {
// lock.RLock()
// defer lock.RUnlock()
// return globalConfig.baseUrl
// }
func DatabaseUrl() string {
lock.RLock()
defer lock.RUnlock()
@ -339,5 +339,13 @@ func ThrottleImportMs() int {
// returns the before, after times, in that order
func ImportWindow() (time.Time, time.Time) {
lock.RLock()
defer lock.RUnlock()
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)
GetAlbum(ctx context.Context, opts GetAlbumOpts) (*models.Album, 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)
GetTopArtistsPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Artist], 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
SetPrimaryAlbumAlias(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
DeleteArtist(ctx context.Context, id int32) error
DeleteAlbum(ctx context.Context, id int32) error

@ -3,6 +3,7 @@ package psql
import (
"context"
"errors"
"fmt"
"strings"
"time"
@ -41,11 +42,11 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
Column1: opts.Titles,
})
} else {
return nil, errors.New("insufficient information to get album")
return nil, errors.New("GetAlbum: insufficient information to get album")
}
if err != nil {
return nil, err
return nil, fmt.Errorf("GetAlbum: %w", err)
}
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,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetAlbum: CountListensFromRelease: %w", err)
}
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,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err)
}
return &models.Album{
@ -87,17 +88,17 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al
insertImage = &opts.Image
}
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 {
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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return nil, err
return nil, fmt.Errorf("SaveAlbum: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
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 != ""},
})
if err != nil {
return nil, err
return nil, fmt.Errorf("SaveAlbum: InsertRelease: %w", err)
}
for _, artistId := range opts.ArtistIDs {
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,
})
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)
@ -130,11 +131,12 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al
})
if err != nil {
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)
if err != nil {
return nil, err
return nil, fmt.Errorf("SaveAlbum: Commit: %w", err)
}
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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("AddArtistsToAlbum: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
@ -162,6 +164,7 @@ func (d *Psql) AddArtistsToAlbum(ctx context.Context, opts db.AddArtistsToAlbumO
})
if err != nil {
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)
@ -175,7 +178,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error {
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("UpdateAlbum: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
@ -186,7 +189,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error {
MusicBrainzID: &opts.MusicBrainzID,
})
if err != nil {
return err
return fmt.Errorf("UpdateAlbum: UpdateReleaseMbzID: %w", err)
}
}
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 != ""},
})
if err != nil {
return err
return fmt.Errorf("UpdateAlbum: UpdateReleaseImage: %w", err)
}
}
if opts.VariousArtistsUpdate {
@ -207,7 +210,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error {
VariousArtists: opts.VariousArtistsValue,
})
if err != nil {
return err
return fmt.Errorf("UpdateAlbum: UpdateReleaseVariousArtists: %w", err)
}
}
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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("SaveAlbumAliases: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
existing, err := qtx.GetAllReleaseAliases(ctx, id)
if err != nil {
return err
return fmt.Errorf("SaveAlbumAliases: GetAllReleaseAliases: %w", err)
}
for _, v := range existing {
aliases = append(aliases, v.Alias)
@ -235,7 +238,7 @@ func (d *Psql) SaveAlbumAliases(ctx context.Context, id int32, aliases []string,
utils.Unique(&aliases)
for _, alias := range aliases {
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{
Alias: strings.TrimSpace(alias),
@ -244,7 +247,7 @@ func (d *Psql) SaveAlbumAliases(ctx context.Context, id int32, aliases []string,
IsPrimary: false,
})
if err != nil {
return err
return fmt.Errorf("SaveAlbumAliases: InsertReleaseAlias: %w", err)
}
}
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) {
rows, err := d.q.GetAllReleaseAliases(ctx, id)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetAllAlbumAliases: GetAllReleaseAliases: %w", err)
}
aliases := make([]models.Alias, len(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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("SetPrimaryAlbumAlias: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
// get all aliases
aliases, err := qtx.GetAllReleaseAliases(ctx, id)
if err != nil {
return err
return fmt.Errorf("SetPrimaryAlbumAlias: GetAllReleaseAliases: %w", err)
}
primary := ""
exists := false
@ -309,7 +312,7 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string)
return nil
}
if !exists {
return errors.New("alias does not exist")
return errors.New("SetPrimaryAlbumAlias: alias does not exist")
}
err = qtx.SetReleaseAliasPrimaryStatus(ctx, repository.SetReleaseAliasPrimaryStatusParams{
ReleaseID: id,
@ -317,7 +320,7 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string)
IsPrimary: true,
})
if err != nil {
return err
return fmt.Errorf("SetPrimaryAlbumAlias: SetReleaseAliasPrimaryStatus: %w", err)
}
err = qtx.SetReleaseAliasPrimaryStatus(ctx, repository.SetReleaseAliasPrimaryStatusParams{
ReleaseID: id,
@ -325,7 +328,61 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string)
IsPrimary: false,
})
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)
}

@ -3,6 +3,7 @@ package psql
import (
"context"
"errors"
"fmt"
"strings"
"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)
row, err := d.q.GetArtist(ctx, opts.ID)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetArtist: GetArtist by ID: %w", err)
}
count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{
ListenedAt: time.Unix(0, 0),
@ -31,14 +32,14 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
ArtistID: row.ID,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
}
return &models.Artist{
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)
row, err := d.q.GetArtistByMbzID(ctx, &opts.MusicBrainzID)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetArtist: GetArtistByMbzID: %w", err)
}
count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{
ListenedAt: time.Unix(0, 0),
@ -61,14 +62,14 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
ArtistID: row.ID,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
}
return &models.Artist{
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)
row, err := d.q.GetArtistByName(ctx, opts.Name)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetArtist: GetArtistByName: %w", err)
}
count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{
ListenedAt: time.Unix(0, 0),
@ -91,14 +92,14 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
ArtistID: row.ID,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
}
return &models.Artist{
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 {
l := logger.FromContext(ctx)
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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("SaveArtistAliases: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
existing, err := qtx.GetAllArtistAliases(ctx, id)
if err != nil {
return err
return fmt.Errorf("SaveArtistAliases: GetAllArtistAliases: %w", err)
}
for _, v := range existing {
aliases = append(aliases, v.Alias)
}
utils.Unique(&aliases)
for _, alias := range aliases {
if strings.TrimSpace(alias) == "" {
return errors.New("aliases cannot be blank")
alias = strings.TrimSpace(alias)
if alias == "" {
return errors.New("SaveArtistAliases: aliases cannot be blank")
}
err = qtx.InsertArtistAlias(ctx, repository.InsertArtistAliasParams{
Alias: strings.TrimSpace(alias),
Alias: alias,
ArtistID: id,
Source: source,
IsPrimary: false,
})
if err != nil {
return err
return fmt.Errorf("SaveArtistAliases: InsertArtistAlias: %w", err)
}
}
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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return nil, err
return nil, fmt.Errorf("SaveArtist: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
opts.Name = strings.TrimSpace(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)
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 != ""},
})
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)
err = qtx.InsertArtistAlias(ctx, repository.InsertArtistAliasParams{
@ -195,13 +197,13 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models.
IsPrimary: true,
})
if err != nil {
l.Error().Err(err).Msgf("Error inserting canonical alias for artist '%s'", opts.Name)
return nil, err
l.Err(err).Msgf("SaveArtist: error inserting canonical alias for artist '%s'", opts.Name)
return nil, fmt.Errorf("SaveArtist: InsertArtistAlias: %w", err)
}
err = tx.Commit(ctx)
if err != nil {
l.Err(err).Msg("Failed to commit insert artist transaction")
return nil, err
return nil, fmt.Errorf("SaveArtist: Commit: %w", err)
}
artist := &models.Artist{
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)
err = d.SaveArtistAliases(ctx, a.ID, opts.Aliases, "MusicBrainz")
if err != nil {
return nil, err
return nil, fmt.Errorf("SaveArtist: SaveArtistAliases: %w", err)
}
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 {
l := logger.FromContext(ctx)
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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("UpdateArtist: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
@ -240,7 +242,7 @@ func (d *Psql) UpdateArtist(ctx context.Context, opts db.UpdateArtistOpts) error
MusicBrainzID: &opts.MusicBrainzID,
})
if err != nil {
return err
return fmt.Errorf("UpdateArtist: UpdateArtistMbzID: %w", err)
}
}
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 != ""},
})
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 {
@ -263,10 +270,11 @@ func (d *Psql) DeleteArtistAlias(ctx context.Context, id int32, alias string) er
Alias: alias,
})
}
func (d *Psql) GetAllArtistAliases(ctx context.Context, id int32) ([]models.Alias, error) {
rows, err := d.q.GetAllArtistAliases(ctx, id)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetAllArtistAliases: %w", err)
}
aliases := make([]models.Alias, len(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 {
l := logger.FromContext(ctx)
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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("SetPrimaryArtistAlias: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
// get all aliases
aliases, err := qtx.GetAllArtistAliases(ctx, id)
if err != nil {
return err
return fmt.Errorf("SetPrimaryArtistAlias: GetAllArtistAliases: %w", err)
}
primary := ""
exists := false
@ -308,11 +315,10 @@ func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string
}
}
if primary == alias {
// no-op rename
return nil
}
if !exists {
return errors.New("alias does not exist")
return errors.New("SetPrimaryArtistAlias: alias does not exist")
}
err = qtx.SetArtistAliasPrimaryStatus(ctx, repository.SetArtistAliasPrimaryStatusParams{
ArtistID: id,
@ -320,7 +326,7 @@ func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string
IsPrimary: true,
})
if err != nil {
return err
return fmt.Errorf("SetPrimaryArtistAlias: SetArtistAliasPrimaryStatus (primary): %w", err)
}
err = qtx.SetArtistAliasPrimaryStatus(ctx, repository.SetArtistAliasPrimaryStatusParams{
ArtistID: id,
@ -328,7 +334,57 @@ func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string
IsPrimary: false,
})
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 (
"context"
"errors"
"fmt"
"time"
"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,
})
if err != nil {
return 0, err
return 0, fmt.Errorf("CountListens: %w", err)
}
return count, nil
}
func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(period)
@ -29,10 +31,11 @@ func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error)
ListenedAt_2: t2,
})
if err != nil {
return 0, err
return 0, fmt.Errorf("CountTracks: %w", err)
}
return count, nil
}
func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(period)
@ -41,10 +44,11 @@ func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error)
ListenedAt_2: t2,
})
if err != nil {
return 0, err
return 0, fmt.Errorf("CountAlbums: %w", err)
}
return count, nil
}
func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(period)
@ -53,10 +57,11 @@ func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error
ListenedAt_2: t2,
})
if err != nil {
return 0, err
return 0, fmt.Errorf("CountArtists: %w", err)
}
return count, nil
}
func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(period)
@ -65,10 +70,11 @@ func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64,
ListenedAt_2: t2,
})
if err != nil {
return 0, err
return 0, fmt.Errorf("CountTimeListened: %w", err)
}
return count, nil
}
func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(opts.Period)
@ -80,7 +86,7 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened
ArtistID: opts.ArtistID,
})
if err != nil {
return 0, err
return 0, fmt.Errorf("CountTimeListenedToItem (Artist): %w", err)
}
return count, nil
} else if opts.AlbumID > 0 {
@ -90,10 +96,9 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened
ReleaseID: opts.AlbumID,
})
if err != nil {
return 0, err
return 0, fmt.Errorf("CountTimeListenedToItem (Album): %w", err)
}
return count, nil
} else if opts.TrackID > 0 {
count, err := p.q.CountTimeListenedToTrack(ctx, repository.CountTimeListenedToTrackParams{
ListenedAt: t1,
@ -101,9 +106,9 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened
ID: opts.TrackID,
})
if err != nil {
return 0, err
return 0, fmt.Errorf("CountTimeListenedToItem (Track): %w", err)
}
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"
"encoding/json"
"errors"
"fmt"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/models"
@ -15,15 +16,15 @@ import (
func (d *Psql) ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error) {
_, err := d.q.GetReleaseByImageID(ctx, &image)
if err == nil {
return true, err
return true, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
return false, err
return false, fmt.Errorf("ImageHasAssociation: GetReleaseByImageID: %w", err)
}
_, err = d.q.GetArtistByImage(ctx, &image)
if err == nil {
return true, err
return true, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
return false, err
return false, fmt.Errorf("ImageHasAssociation: GetArtistByImage: %w", err)
}
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) {
r, err := d.q.GetReleaseByImageID(ctx, &image)
if err == nil {
return r.ImageSource.String, err
return r.ImageSource.String, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
return "", err
return "", fmt.Errorf("GetImageSource: GetReleaseByImageID: %w", err)
}
rr, err := d.q.GetArtistByImage(ctx, &image)
if err == nil {
return rr.ImageSource.String, err
return rr.ImageSource.String, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
return "", err
return "", fmt.Errorf("GetImageSource: GetArtistByImage: %w", err)
}
return "", nil
}
@ -51,14 +52,13 @@ func (d *Psql) AlbumsWithoutImages(ctx context.Context, from int32) ([]*models.A
ID: from,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("AlbumsWithoutImages: GetReleasesWithoutImages: %w", err)
}
albums := make([]*models.Album, len(rows))
for i, row := range rows {
artists := make([]models.SimpleArtist, 0)
err = json.Unmarshal(row.Artists, &artists)
if err != nil {
l.Err(err).Msgf("Error unmarshalling artists for release group with id %d", row.ID)
var artists []models.SimpleArtist
if err := json.Unmarshal(row.Artists, &artists); err != nil {
l.Err(err).Msgf("AlbumsWithoutImages: error unmarshalling artists for release group with id %d", row.ID)
artists = nil
}
albums[i] = &models.Album{

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"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
t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetListensPaginated: %w", err)
}
if opts.Month == 0 && opts.Year == 0 {
// use period, not date range
@ -41,7 +42,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
ID: int32(opts.TrackID),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetListensPaginated: GetLastListensFromTrackPaginated: %w", err)
}
listens = make([]*models.Listen, len(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)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err)
}
listens[i] = t
}
@ -64,7 +65,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
TrackID: int32(opts.TrackID),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetListensPaginated: CountListensFromTrack: %w", err)
}
} else if opts.AlbumID > 0 {
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),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetListensPaginated: GetLastListensFromReleasePaginated: %w", err)
}
listens = make([]*models.Listen, len(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)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err)
}
listens[i] = t
}
@ -100,7 +101,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
ReleaseID: int32(opts.AlbumID),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetListensPaginated: CountListensFromRelease: %w", err)
}
} else if opts.ArtistID > 0 {
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),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetListensPaginated: GetLastListensFromArtistPaginated: %w", err)
}
listens = make([]*models.Listen, len(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)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err)
}
listens[i] = t
}
@ -136,7 +137,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
ArtistID: int32(opts.ArtistID),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetListensPaginated: CountListensFromArtist: %w", err)
}
} else {
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),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetListensPaginated: GetLastListensPaginated: %w", err)
}
listens = make([]*models.Listen, len(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)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err)
}
listens[i] = t
}
@ -170,7 +171,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
ListenedAt_2: t2,
})
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)
}

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

@ -34,34 +34,34 @@ func New() (*Psql, error) {
config, err := pgxpool.ParseConfig(cfg.DatabaseUrl())
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
pool, err := pgxpool.NewWithConfig(ctx, config)
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 {
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())
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)
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")
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()

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

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

@ -3,6 +3,7 @@ package psql
import (
"context"
"encoding/json"
"fmt"
"time"
"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
t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTopAlbumsPaginated: %w", err)
}
if opts.Month == 0 && opts.Year == 0 {
// use period, not date range
@ -43,7 +44,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
ListenedAt_2: t2,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTopAlbumsPaginated: GetTopReleasesFromArtist: %w", err)
}
rgs = make([]*models.Album, 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)
if err != nil {
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{
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))
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTopAlbumsPaginated: CountReleasesFromArtist: %w", err)
}
} else {
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),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTopAlbumsPaginated: GetTopReleasesPaginated: %w", err)
}
rgs = make([]*models.Album, 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)
if err != nil {
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{
Title: row.Title,
@ -105,7 +106,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
ListenedAt_2: t2,
})
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)
}

@ -2,6 +2,7 @@ package psql
import (
"context"
"fmt"
"time"
"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
t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTopArtistsPaginated: %w", err)
}
if opts.Month == 0 && opts.Year == 0 {
// use period, not date range
@ -35,7 +36,7 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts)
Offset: int32(offset),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTopArtistsPaginated: GetTopArtistsPaginated: %w", err)
}
rgs := make([]*models.Artist, len(rows))
for i, row := range rows {
@ -53,7 +54,7 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts)
ListenedAt_2: t2,
})
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)

@ -3,6 +3,7 @@ package psql
import (
"context"
"encoding/json"
"fmt"
"time"
"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
t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTopTracksPaginated: %w", err)
}
if opts.Month == 0 && opts.Year == 0 {
// use period, not date range
@ -40,7 +41,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
ReleaseID: int32(opts.AlbumID),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTopTracksPaginated: GetTopTracksInReleasePaginated: %w", err)
}
tracks = make([]*models.Track, len(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)
if err != nil {
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{
Title: row.Title,
@ -80,7 +81,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
ArtistID: int32(opts.ArtistID),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTopTracksPaginated: GetTopTracksByArtistPaginated: %w", err)
}
tracks = make([]*models.Track, len(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)
if err != nil {
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{
Title: row.Title,
@ -107,7 +108,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
ArtistID: int32(opts.ArtistID),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTopTracksPaginated: CountTopTracksByArtist: %w", err)
}
} else {
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),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTopTracksPaginated: GetTopTracksPaginated: %w", err)
}
tracks = make([]*models.Track, len(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)
if err != nil {
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{
Title: row.Title,
@ -145,7 +146,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
ListenedAt_2: t2,
})
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)
}

@ -3,6 +3,7 @@ package psql
import (
"context"
"errors"
"fmt"
"strings"
"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)
t, err := d.q.GetTrack(ctx, opts.ID)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTrack: GetTrack By ID: %w", err)
}
track = models.Track{
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)
t, err := d.q.GetTrackByMbzID(ctx, &opts.MusicBrainzID)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTrack: GetTrackByMbzID: %w", err)
}
track = models.Track{
ID: t.ID,
@ -53,7 +54,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
Column2: opts.ArtistIDs,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTrack: GetTrackByTitleAndArtists: %w", err)
}
track = models.Track{
ID: t.ID,
@ -63,7 +64,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
Duration: t.Duration,
}
} 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{
@ -72,7 +73,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
TrackID: track.ID,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTrack: CountListensFromTrack: %w", err)
}
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,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err)
}
track.ListenCount = count
@ -97,20 +98,20 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
insertMbzID = &opts.RecordingMbzID
}
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 {
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 {
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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return nil, err
return nil, fmt.Errorf("SaveTrack: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
@ -120,7 +121,7 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
ReleaseID: opts.AlbumID,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("SaveTrack: InsertTrack: %w", err)
}
// insert associated artists
for _, aid := range opts.ArtistIDs {
@ -129,7 +130,7 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
TrackID: trackRow.ID,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("SaveTrack: AssociateArtistToTrack: %w", err)
}
}
// insert primary alias
@ -140,11 +141,11 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
IsPrimary: true,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("SaveTrack: InsertTrackAlias: %w", err)
}
err = tx.Commit(ctx)
if err != nil {
return nil, err
return nil, fmt.Errorf("SaveTrack: Commit: %w", err)
}
return &models.Track{
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 {
l := logger.FromContext(ctx)
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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("UpdateTrack: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
@ -172,7 +173,7 @@ func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error {
MusicBrainzID: &opts.MusicBrainzID,
})
if err != nil {
return err
return fmt.Errorf("UpdateTrack: UpdateTrackMbzID: %w", err)
}
}
if opts.Duration != 0 {
@ -182,7 +183,7 @@ func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error {
Duration: opts.Duration,
})
if err != nil {
return err
return fmt.Errorf("UpdateTrack: UpdateTrackDuration: %w", err)
}
}
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 {
l := logger.FromContext(ctx)
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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("SaveTrackAliases: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
existing, err := qtx.GetAllTrackAliases(ctx, id)
if err != nil {
return err
return fmt.Errorf("SaveTrackAliases: GetAllTrackAliases: %w", err)
}
for _, v := range existing {
aliases = append(aliases, v.Alias)
@ -219,7 +220,7 @@ func (d *Psql) SaveTrackAliases(ctx context.Context, id int32, aliases []string,
IsPrimary: false,
})
if err != nil {
return err
return fmt.Errorf("SaveTrackAliases: InsertTrackAlias: %w", err)
}
}
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) {
rows, err := d.q.GetAllTrackAliases(ctx, id)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetAllTrackAliases: GetAllTrackAliases: %w", err)
}
aliases := make([]models.Alias, len(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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("SetPrimaryTrackAlias: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
// get all aliases
aliases, err := qtx.GetAllTrackAliases(ctx, id)
if err != nil {
return err
return fmt.Errorf("SetPrimaryTrackAlias: GetAllTrackAliases: %w", err)
}
primary := ""
exists := false
@ -293,7 +294,7 @@ func (d *Psql) SetPrimaryTrackAlias(ctx context.Context, id int32, alias string)
IsPrimary: true,
})
if err != nil {
return err
return fmt.Errorf("SetPrimaryTrackAlias: SetTrackAliasPrimaryStatus: %w", err)
}
err = qtx.SetTrackAliasPrimaryStatus(ctx, repository.SetTrackAliasPrimaryStatusParams{
TrackID: id,
@ -301,7 +302,61 @@ func (d *Psql) SetPrimaryTrackAlias(ctx context.Context, id int32, alias string)
IsPrimary: false,
})
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)
}

@ -3,6 +3,7 @@ package psql
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"unicode/utf8"
@ -21,7 +22,7 @@ func (d *Psql) GetUserByUsername(ctx context.Context, username string) (*models.
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
} else if err != nil {
return nil, err
return nil, fmt.Errorf("GetUserByUsername: %w", err)
}
return &models.User{
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) {
return nil, nil
} else if err != nil {
return nil, err
return nil, fmt.Errorf("GetUserByApiKey: %w", err)
}
return &models.User{
ID: row.ID,
@ -52,12 +53,12 @@ func (d *Psql) SaveUser(ctx context.Context, opts db.SaveUserOpts) (*models.User
err := ValidateUsername(opts.Username)
if err != nil {
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)
if err != nil {
l.Debug().AnErr("validator_notice", err).Msgf("Password failed validation")
return nil, err
return nil, fmt.Errorf("SaveUser: ValidateAndNormalizePassword: %w", err)
}
if opts.Role == "" {
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)
if err != nil {
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{
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),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("SaveUser: InsertUser: %w", err)
}
return &models.User{
ID: u.ID,
@ -88,7 +89,7 @@ func (d *Psql) SaveApiKey(ctx context.Context, opts db.SaveApiKeyOpts) (*models.
UserID: opts.UserID,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("SaveApiKey: InsertApiKey: %w", err)
}
return &models.ApiKey{
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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("UpdateUser: BeginTx: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
@ -115,33 +116,33 @@ func (d *Psql) UpdateUser(ctx context.Context, opts db.UpdateUserOpts) error {
err := ValidateUsername(opts.Username)
if err != nil {
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{
ID: opts.ID,
Username: opts.Username,
})
if err != nil {
return err
return fmt.Errorf("UpdateUser: UpdateUserUsername: %w", err)
}
}
if opts.Password != "" {
pw, err := ValidateAndNormalizePassword(opts.Password)
if err != nil {
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)
if err != nil {
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{
ID: opts.ID,
Password: hashPw,
})
if err != nil {
return err
return fmt.Errorf("UpdateUser: UpdateUserPassword: %w", err)
}
}
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) {
rows, err := d.q.GetAllApiKeysByUserID(ctx, id)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetApiKeysByUserID: %w", err)
}
keys := make([]models.ApiKey, len(rows))
for i, row := range rows {

@ -53,7 +53,7 @@ func NewDeezerClient() *DeezerClient {
ret := new(DeezerClient)
ret.url = deezerBaseUrl
ret.userAgent = cfg.UserAgent()
ret.requestQueue = queue.NewRequestQueue(1, 1)
ret.requestQueue = queue.NewRequestQueue(5, 5)
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)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
return fmt.Errorf("getEntity: %w", err)
}
l.Debug().Msg("Adding ImageSrc request to queue")
body, err := c.queue(ctx, req)
if err != nil {
l.Err(err).Msg("Deezer request failed")
return err
return fmt.Errorf("getEntity: %w", err)
}
err = json.Unmarshal(body, result)
if err != nil {
l.Err(err).Msg("Failed to unmarshal Deezer response")
return err
return fmt.Errorf("getEntity: %w", err)
}
return nil
@ -121,10 +121,10 @@ func (c *DeezerClient) GetArtistImages(ctx context.Context, aliases []string) (s
for _, a := range aliasesAscii {
err := c.getEntity(ctx, fmt.Sprintf(artistImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"", a))), resp)
if err != nil {
return "", err
return "", fmt.Errorf("GetArtistImages: %w", err)
}
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 {
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) {
err := c.getEntity(ctx, fmt.Sprintf(artistImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"", a))), resp)
if err != nil {
return "", err
return "", fmt.Errorf("GetArtistImages: %w", err)
}
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 {
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) {
@ -163,7 +163,7 @@ func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, alb
for _, alias := range artists {
err := c.getEntity(ctx, fmt.Sprintf(albumImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"album:\"%s\"", alias, album))), resp)
if err != nil {
return "", err
return "", fmt.Errorf("GetAlbumImages: %w", err)
}
if len(resp.Data) > 0 {
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
err := c.getEntity(ctx, fmt.Sprintf(albumImageEndpoint, url.QueryEscape(fmt.Sprintf("album:\"%s\"", album))), resp)
if err != nil {
return "", err
return "", fmt.Errorf("GetAlbumImages: %w", err)
}
for _, v := range resp.Data {
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
}
l.Warn().Msg("No image providers are enabled")
l.Warn().Msg("GetArtistImage: No image providers are enabled")
return "", nil
}
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
}
l.Warn().Msg("No image providers are enabled")
l.Warn().Msg("GetAlbumImage: No image providers are enabled")
return "", nil
}

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

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

@ -3,6 +3,7 @@ package importer
import (
"context"
"encoding/json"
"fmt"
"os"
"path"
"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))
if err != nil {
l.Err(err).Msgf("Failed to read import file: %s", filename)
return err
return fmt.Errorf("ImportMalojaFile: %w", err)
}
defer file.Close()
var throttleFunc = func() {}
@ -49,7 +50,7 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error {
export := new(MalojaExport)
err = json.NewDecoder(file).Decode(&export)
if err != nil {
return err
return fmt.Errorf("ImportMalojaFile: %w", err)
}
for _, item := range export.Scrobbles {
martists := make([]string, 0)
@ -71,19 +72,20 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error {
continue
}
opts := catalog.SubmitListenOpts{
MbzCaller: &mbz.MusicBrainzClient{},
Artist: item.Track.Artists[0],
ArtistNames: artists,
TrackTitle: item.Track.Title,
ReleaseTitle: item.Track.Album.Title,
Time: ts.Local(),
Client: "maloja",
UserID: 1,
MbzCaller: &mbz.MusicBrainzClient{},
Artist: item.Track.Artists[0],
ArtistNames: artists,
TrackTitle: item.Track.Title,
ReleaseTitle: item.Track.Album.Title,
Time: ts.Local(),
Client: "maloja",
UserID: 1,
SkipCacheImage: !cfg.FetchImagesDuringImport(),
}
err = catalog.SubmitListen(ctx, store, opts)
if err != nil {
l.Err(err).Msg("Failed to import maloja playback item")
return err
return fmt.Errorf("ImportMalojaFile: %w", err)
}
throttleFunc()
}

@ -3,6 +3,7 @@ package importer
import (
"context"
"encoding/json"
"fmt"
"os"
"path"
"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))
if err != nil {
l.Err(err).Msgf("Failed to read import file: %s", filename)
return err
return fmt.Errorf("ImportSpotifyFile: %w", err)
}
defer file.Close()
var throttleFunc = func() {}
@ -41,7 +42,7 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error
export := make([]SpotifyExportItem, 0)
err = json.NewDecoder(file).Decode(&export)
if err != nil {
return err
return fmt.Errorf("ImportSpotifyFile: %w", err)
}
for _, item := range export {
@ -58,19 +59,20 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error
continue
}
opts := catalog.SubmitListenOpts{
MbzCaller: &mbz.MusicBrainzClient{},
Artist: item.ArtistName,
TrackTitle: item.TrackName,
ReleaseTitle: item.AlbumName,
Duration: dur / 1000,
Time: item.Timestamp,
Client: "spotify",
UserID: 1,
MbzCaller: &mbz.MusicBrainzClient{},
Artist: item.ArtistName,
TrackTitle: item.TrackName,
ReleaseTitle: item.AlbumName,
Duration: dur / 1000,
Time: item.Timestamp,
Client: "spotify",
UserID: 1,
SkipCacheImage: !cfg.FetchImagesDuringImport(),
}
err = catalog.SubmitListen(ctx, store, opts)
if err != nil {
l.Err(err).Msg("Failed to import spotify playback item")
return err
return fmt.Errorf("ImportSpotifyFile: %w", err)
}
throttleFunc()
}

@ -3,6 +3,7 @@ package mbz
import (
"context"
"errors"
"fmt"
"slices"
"github.com/gabehf/koito/internal/logger"
@ -28,7 +29,7 @@ func (c *MusicBrainzClient) getArtist(ctx context.Context, id uuid.UUID) (*Music
mbzArtist := new(MusicBrainzArtist)
err := c.getEntity(ctx, artistAliasFmtStr, id, mbzArtist)
if err != nil {
return nil, err
return nil, fmt.Errorf("getArtist: %w", err)
}
return mbzArtist, nil
}
@ -38,10 +39,10 @@ func (c *MusicBrainzClient) GetArtistPrimaryAliases(ctx context.Context, id uuid
l := logger.FromContext(ctx)
artist, err := c.getArtist(ctx, id)
if err != nil {
return nil, err
return nil, fmt.Errorf("GetArtistPrimaryAliases: %w", err)
}
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)
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)
if err != nil {
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")
body, err := c.queue(ctx, req)
if err != nil {
l.Err(err).Msg("MusicBrainz request failed")
return err
return fmt.Errorf("getEntity: %w", err)
}
err = json.Unmarshal(body, result)
if err != nil {
l.Err(err).Str("body", string(body)).Msg("Failed to unmarshal MusicBrainz response body")
return err
return fmt.Errorf("getEntity: %w", err)
}
return nil

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

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

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

@ -199,28 +199,39 @@ func (q *Queries) GetArtistByName(ctx context.Context, alias string) (GetArtistB
const getReleaseArtists = `-- name: GetReleaseArtists :many
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
LEFT JOIN artist_releases ar ON a.id = ar.artist_id
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)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ArtistsWithName
var items []GetReleaseArtistsRow
for rows.Next() {
var i ArtistsWithName
var i GetReleaseArtistsRow
if err := rows.Scan(
&i.ID,
&i.MusicBrainzID,
&i.Image,
&i.ImageSource,
&i.Name,
&i.IsPrimary,
); err != nil {
return nil, err
}
@ -297,28 +308,39 @@ func (q *Queries) GetTopArtistsPaginated(ctx context.Context, arg GetTopArtistsP
const getTrackArtists = `-- name: GetTrackArtists :many
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
LEFT JOIN artist_tracks at ON a.id = at.artist_id
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)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ArtistsWithName
var items []GetTrackArtistsRow
for rows.Next() {
var i ArtistsWithName
var i GetTrackArtistsRow
if err := rows.Scan(
&i.ID,
&i.MusicBrainzID,
&i.Image,
&i.ImageSource,
&i.Name,
&i.IsPrimary,
); err != nil {
return nil, err
}

@ -194,12 +194,7 @@ SELECT
l.track_id, l.listened_at, l.client, l.user_id,
t.title AS track_title,
t.release_id AS release_id,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.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,
t.title AS track_title,
t.release_id AS release_id,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2
@ -337,12 +327,7 @@ SELECT
l.track_id, l.listened_at, l.client, l.user_id,
t.title AS track_title,
t.release_id AS release_id,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2
@ -408,12 +393,7 @@ SELECT
l.track_id, l.listened_at, l.client, l.user_id,
t.title AS track_title,
t.release_id AS release_id,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
WHERE l.listened_at BETWEEN $1 AND $2

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

@ -197,12 +197,7 @@ func (q *Queries) GetReleaseByMbzID(ctx context.Context, musicbrainzID *uuid.UUI
const getReleasesWithoutImages = `-- name: GetReleasesWithoutImages :many
SELECT
r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title,
(
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
get_artists_for_release(r.id) AS artists
FROM releases_with_title r
WHERE r.image IS NULL
AND r.id > $2
@ -257,12 +252,7 @@ const getTopReleasesFromArtist = `-- name: GetTopReleasesFromArtist :many
SELECT
r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title,
COUNT(*) AS listen_count,
(
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
get_artists_for_release(r.id) AS artists
FROM listens l
JOIN tracks t ON l.track_id = t.id
JOIN releases_with_title r ON t.release_id = r.id
@ -332,12 +322,7 @@ const getTopReleasesPaginated = `-- name: GetTopReleasesPaginated :many
SELECT
r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title,
COUNT(*) AS listen_count,
(
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
get_artists_for_release(r.id) AS artists
FROM listens l
JOIN tracks t ON l.track_id = t.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
}
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
UPDATE releases SET various_artists = $2
WHERE id = $1

@ -136,12 +136,7 @@ SELECT
ranked.image,
ranked.various_artists,
ranked.score,
(
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
get_artists_for_release(ranked.id) AS artists
FROM (
SELECT
r.id,
@ -211,12 +206,7 @@ SELECT
ranked.image,
ranked.various_artists,
ranked.score,
(
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
get_artists_for_release(ranked.id) AS artists
FROM (
SELECT
r.id,
@ -286,12 +276,7 @@ SELECT
ranked.release_id,
ranked.image,
ranked.score,
(
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
get_artists_for_track(ranked.id) AS artists
FROM (
SELECT
t.id,
@ -362,12 +347,7 @@ SELECT
ranked.release_id,
ranked.image,
ranked.score,
(
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
get_artists_for_track(ranked.id) AS artists
FROM (
SELECT
t.id,

@ -138,12 +138,7 @@ SELECT
t.release_id,
r.image,
COUNT(*) AS listen_count,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
JOIN releases r ON t.release_id = r.id
@ -215,12 +210,7 @@ SELECT
t.release_id,
r.image,
COUNT(*) AS listen_count,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
JOIN releases r ON t.release_id = r.id
@ -291,12 +281,7 @@ SELECT
t.release_id,
r.image,
COUNT(*) AS listen_count,
(
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
get_artists_for_track(t.id) AS artists
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.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)
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) {
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) {
return time.Time{}, time.Time{}, errors.New("invalid week")
return time.Time{}, time.Time{}, errors.New("DateRange: invalid week")
}
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
if week != 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
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) {
sfi, err := os.Stat(src)
if err != nil {
return
return fmt.Errorf("CopyFile: %w", err)
}
if !sfi.Mode().IsRegular() {
// cannot copy non-regular files (e.g., directories,
// 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)
if err != nil {
if !os.IsNotExist(err) {
return
return fmt.Errorf("CopyFile: %w", err)
}
} else {
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) {
return
return fmt.Errorf("CopyFile: %w", err)
}
}
if err = os.Link(src, dst); err == nil {
return
return fmt.Errorf("CopyFile: %w", err)
}
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
@ -167,24 +170,22 @@ func CopyFile(src, dst string) (err error) {
func copyFileContents(src, dst string) (err error) {
in, err := os.Open(src)
if err != nil {
return
return fmt.Errorf("copyFileContents: %w", err)
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return
return fmt.Errorf("copyFileContents: %w", err)
}
defer func() {
cerr := out.Close()
if err == nil {
err = cerr
}
}()
defer out.Close()
if _, err = io.Copy(out, in); err != nil {
return
return fmt.Errorf("copyFileContents: %w", err)
}
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)
@ -281,7 +282,7 @@ func GenerateRandomString(length int) (string, error) {
for i := range length {
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
if err != nil {
return "", err
return "", fmt.Errorf("GenerateRandomString: %w", err)
}
ret[i] = letters[num.Int64()]
}
@ -311,3 +312,18 @@ func MoreThanOneString(s ...string) bool {
}
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