From 80b6f4deaace6ae25a0d778d37f071f332179c4e Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Mon, 16 Jun 2025 21:55:39 -0400 Subject: [PATCH] feat: v0.0.8 --- CHANGELOG.md | 21 ++- client/api/api.ts | 1 + client/app/components/SearchResults.tsx | 2 +- client/app/components/TopItemList.tsx | 100 ++++------- .../modals/{ => EditModal}/EditModal.tsx | 11 +- .../modals/EditModal/SetPrimaryArtist.tsx | 99 +++++++++++ .../{ => EditModal}/SetVariousArtist.tsx | 1 + client/app/components/modals/MergeModal.tsx | 8 +- client/app/routes/Home.tsx | 2 +- client/app/routes/MediaItems/MediaLayout.tsx | 8 +- db/migrations/000003_add_primary_artist.sql | 48 ++++++ db/queries/artist.sql | 10 +- db/queries/listen.sql | 28 +-- db/queries/release.sql | 25 +-- db/queries/search.sql | 28 +-- db/queries/track.sql | 25 +-- .../content/docs/reference/configuration.md | 3 + engine/handlers/alias.go | 124 ++++++++------ engine/handlers/apikeys.go | 79 ++++----- engine/handlers/artists.go | 156 +++++++++++++++++ engine/handlers/auth.go | 137 +++++++-------- engine/handlers/delete.go | 4 - engine/handlers/image_handler.go | 11 +- engine/handlers/lbz_submit_listen.go | 8 +- engine/long_test.go | 160 +++++++++++++++++- engine/routes.go | 2 + internal/catalog/associate_album.go | 96 +++++++---- internal/catalog/associate_artists.go | 143 +++++++++------- internal/catalog/associate_track.go | 17 +- internal/catalog/catalog.go | 31 ++-- internal/catalog/images.go | 56 +++--- internal/cfg/cfg.go | 118 +++++++------ internal/db/db.go | 4 + internal/db/psql/album.go | 107 +++++++++--- internal/db/psql/artist.go | 132 ++++++++++----- internal/db/psql/counts.go | 25 +-- internal/db/psql/images.go | 26 +-- internal/db/psql/listen.go | 27 +-- internal/db/psql/listen_activity.go | 9 +- internal/db/psql/merge.go | 2 +- internal/db/psql/psql.go | 12 +- internal/db/psql/search.go | 21 +-- internal/db/psql/sessions.go | 5 +- internal/db/psql/top_albums.go | 15 +- internal/db/psql/top_artists.go | 7 +- internal/db/psql/top_tracks.go | 19 ++- internal/db/psql/track.go | 109 +++++++++--- internal/db/psql/user.go | 29 ++-- internal/images/deezer.go | 24 +-- internal/images/imagesrc.go | 4 +- internal/importer/lastfm.go | 11 +- internal/importer/listenbrainz.go | 3 +- internal/importer/maloja.go | 24 +-- internal/importer/spotify.go | 24 +-- internal/mbz/artist.go | 7 +- internal/mbz/mbz.go | 6 +- internal/mbz/release.go | 9 +- internal/mbz/track.go | 3 +- internal/models/artist.go | 1 + internal/repository/artist.sql.go | 42 +++-- internal/repository/listen.sql.go | 28 +-- internal/repository/models.go | 6 +- internal/repository/release.sql.go | 37 ++-- internal/repository/search.sql.go | 28 +-- internal/repository/track.sql.go | 37 ++-- internal/utils/utils.go | 60 ++++--- 66 files changed, 1554 insertions(+), 911 deletions(-) rename client/app/components/modals/{ => EditModal}/EditModal.tsx (92%) create mode 100644 client/app/components/modals/EditModal/SetPrimaryArtist.tsx rename client/app/components/modals/{ => EditModal}/SetVariousArtist.tsx (97%) create mode 100644 db/migrations/000003_add_primary_artist.sql create mode 100644 engine/handlers/artists.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b58d284..c9f5535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 \ No newline at end of file +- 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 \ No newline at end of file diff --git a/client/api/api.ts b/client/api/api.ts index fe9f204..ca2cf91 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -226,6 +226,7 @@ type Artist = { listen_count: number musicbrainz_id: string time_listened: number + is_primary: boolean } type Album = { id: number, diff --git a/client/app/components/SearchResults.tsx b/client/app/components/SearchResults.tsx index c0269e8..b2a4566 100644 --- a/client/app/components/SearchResults.tsx +++ b/client/app/components/SearchResults.tsx @@ -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}) diff --git a/client/app/components/TopItemList.tsx b/client/app/components/TopItemList.tsx index 5884e63..491625e 100644 --- a/client/app/components/TopItemList.tsx +++ b/client/app/components/TopItemList.tsx @@ -35,92 +35,52 @@ export default function TopItemList({ 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 ( -
-
handleItemClick("album", album.id)} - onKeyDown={handleKeyDown} - role="link" - tabIndex={0} - aria-label={`View album: ${album.title}`} - style={{ cursor: 'pointer' }} - > - {album.title} -
+
+ + {album.title} + +
+ {album.title} -
- {album.is_various_artists ? - Various Artists - : -
- -
- } -
{album.listen_count} plays
+ +
+ {album.is_various_artists ? + Various Artists + : +
+
+ } +
{album.listen_count} plays
); } case "track": { const track = item as Track; - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - handleItemClick("track", track.id); - } - }; return ( -
-
handleItemClick("track", track.id)} - onKeyDown={handleKeyDown} - role="link" - tabIndex={0} - aria-label={`View track: ${track.title}`} - style={{ cursor: 'pointer' }} - > - {track.title} +
+ + {track.title} +
- {track.title} + + {track.title} +
-
+
{track.listen_count} plays
-
); } @@ -128,12 +88,12 @@ function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artis const artist = item as Artist; return (
- - {artist.name} -
- {artist.name} -
{artist.listen_count} plays
-
+ + {artist.name} +
+ {artist.name} +
{artist.listen_count} plays
+
); diff --git a/client/app/components/modals/EditModal.tsx b/client/app/components/modals/EditModal/EditModal.tsx similarity index 92% rename from client/app/components/modals/EditModal.tsx rename to client/app/components/modals/EditModal/EditModal.tsx index 539bb9a..78ce169 100644 --- a/client/app/components/modals/EditModal.tsx +++ b/client/app/components/modals/EditModal/EditModal.tsx @@ -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() const [displayData, setDisplayData] = useState([]) - 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) {
{ type.toLowerCase() === "album" && + <> + + }
diff --git a/client/app/components/modals/EditModal/SetPrimaryArtist.tsx b/client/app/components/modals/EditModal/SetPrimaryArtist.tsx new file mode 100644 index 0000000..b96536f --- /dev/null +++ b/client/app/components/modals/EditModal/SetPrimaryArtist.tsx @@ -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() + 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; + }, + }); + + useEffect(() => { + if (data) { + for (let a of data) { + if (a.is_primary) { + setPrimary(a) + break + } + } + } + }, [data]) + + if (isError) { + return ( +

Error: {error.message}

+ ) + } + if (isPending) { + return ( +

Loading...

+ ) + } + + 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 ( +
+

Set Primary Artist

+
+ + {err &&

{err}

} + {success &&

{success}

} +
+
+ ); +} \ No newline at end of file diff --git a/client/app/components/modals/SetVariousArtist.tsx b/client/app/components/modals/EditModal/SetVariousArtist.tsx similarity index 97% rename from client/app/components/modals/SetVariousArtist.tsx rename to client/app/components/modals/EditModal/SetVariousArtist.tsx index 8761b9b..c35f332 100644 --- a/client/app/components/modals/SetVariousArtist.tsx +++ b/client/app/components/modals/EditModal/SetVariousArtist.tsx @@ -73,6 +73,7 @@ export default function SetVariousArtists({ id }: Props) { {err &&

{err}

} + {success &&

{success}

}
) diff --git a/client/app/components/modals/MergeModal.tsx b/client/app/components/modals/MergeModal.tsx index 9f3fdcc..d4bec44 100644 --- a/client/app/components/modals/MergeModal.tsx +++ b/client/app/components/modals/MergeModal.tsx @@ -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 = () => { diff --git a/client/app/routes/Home.tsx b/client/app/routes/Home.tsx index 8af882b..52dc9be 100644 --- a/client/app/routes/Home.tsx +++ b/client/app/routes/Home.tsx @@ -33,7 +33,7 @@ export default function Home() { - + diff --git a/client/app/routes/MediaItems/MediaLayout.tsx b/client/app/routes/MediaItems/MediaLayout.tsx index 2dcff0b..968dbe2 100644 --- a/client/app/routes/MediaItems/MediaLayout.tsx +++ b/client/app/routes/MediaItems/MediaLayout.tsx @@ -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 export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse @@ -69,9 +69,9 @@ export default function MediaLayout(props: Props) { content={title} />
-
+
- {props.title} + {props.title}

{props.type}

diff --git a/db/migrations/000003_add_primary_artist.sql b/db/migrations/000003_add_primary_artist.sql new file mode 100644 index 0000000..ca6758f --- /dev/null +++ b/db/migrations/000003_add_primary_artist.sql @@ -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); \ No newline at end of file diff --git a/db/queries/artist.sql b/db/queries/artist.sql index 89eef45..2825092 100644 --- a/db/queries/artist.sql +++ b/db/queries/artist.sql @@ -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 ( diff --git a/db/queries/listen.sql b/db/queries/listen.sql index 9049c4e..5252380 100644 --- a/db/queries/listen.sql +++ b/db/queries/listen.sql @@ -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 diff --git a/db/queries/release.sql b/db/queries/release.sql index 74c5c0a..5a888f1 100644 --- a/db/queries/release.sql +++ b/db/queries/release.sql @@ -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; diff --git a/db/queries/search.sql b/db/queries/search.sql index 979d004..b957a27 100644 --- a/db/queries/search.sql +++ b/db/queries/search.sql @@ -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, diff --git a/db/queries/track.sql b/db/queries/track.sql index 73fce83..97092f6 100644 --- a/db/queries/track.sql +++ b/db/queries/track.sql @@ -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; \ No newline at end of file diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 6524e43..6976bee 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -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. \ No newline at end of file diff --git a/engine/handlers/alias.go b/engine/handlers/alias.go index add1b09..79a7598 100644 --- a/engine/handlers/alias.go +++ b/engine/handlers/alias.go @@ -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 } diff --git a/engine/handlers/apikeys.go b/engine/handlers/apikeys.go index 1a3458b..40aa589 100644 --- a/engine/handlers/apikeys.go +++ b/engine/handlers/apikeys.go @@ -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 } diff --git a/engine/handlers/artists.go b/engine/handlers/artists.go new file mode 100644 index 0000000..d8358d6 --- /dev/null +++ b/engine/handlers/artists.go @@ -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) + } +} diff --git a/engine/handlers/auth.go b/engine/handlers/auth.go index 1b0fa53..2ecc72d 100644 --- a/engine/handlers/auth.go +++ b/engine/handlers/auth.go @@ -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) } } diff --git a/engine/handlers/delete.go b/engine/handlers/delete.go index bb87157..ebd4b3c 100644 --- a/engine/handlers/delete.go +++ b/engine/handlers/delete.go @@ -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() diff --git a/engine/handlers/image_handler.go b/engine/handlers/image_handler.go index 8ad1c54..4b17c96 100644 --- a/engine/handlers/image_handler.go +++ b/engine/handlers/image_handler.go @@ -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 } diff --git a/engine/handlers/lbz_submit_listen.go b/engine/handlers/lbz_submit_listen.go index 34004db..5464a24 100644 --- a/engine/handlers/lbz_submit_listen.go +++ b/engine/handlers/lbz_submit_listen.go @@ -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}) } diff --git a/engine/long_test.go b/engine/long_test.go index 498bd08..20dcc01 100644 --- a/engine/long_test.go +++ b/engine/long_test.go @@ -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") +} diff --git a/engine/routes.go b/engine/routes.go index 18fc164..424daf3 100644 --- a/engine/routes.go +++ b/engine/routes.go @@ -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)) diff --git a/internal/catalog/associate_album.go b/internal/catalog/associate_album.go index af39152..55bc44c 100644 --- a/internal/catalog/associate_album.go +++ b/internal/catalog/associate_album.go @@ -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, diff --git a/internal/catalog/associate_artists.go b/internal/catalog/associate_artists.go index 3e0adf3..232cac7 100644 --- a/internal/catalog/associate_artists.go +++ b/internal/catalog/associate_artists.go @@ -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 diff --git a/internal/catalog/associate_track.go b/internal/catalog/associate_track.go index 5304c0b..635bdb1 100644 --- a/internal/catalog/associate_track.go +++ b/internal/catalog/associate_track.go @@ -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) diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 26b3a09..c9a9a53 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -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") diff --git a/internal/catalog/images.go b/internal/catalog/images.go index ecce26c..bf5aa26 100644 --- a/internal/catalog/images.go +++ b/internal/catalog/images.go @@ -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 } diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index ad15869..ca69a25 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -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 +} diff --git a/internal/db/db.go b/internal/db/db.go index 16cecd1..4eb5458 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 diff --git a/internal/db/psql/album.go b/internal/db/psql/album.go index 111b4cc..4392227 100644 --- a/internal/db/psql/album.go +++ b/internal/db/psql/album.go @@ -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) } diff --git a/internal/db/psql/artist.go b/internal/db/psql/artist.go index 0d9b702..0555111 100644 --- a/internal/db/psql/artist.go +++ b/internal/db/psql/artist.go @@ -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 } diff --git a/internal/db/psql/counts.go b/internal/db/psql/counts.go index c7ab3bb..cecdd8d 100644 --- a/internal/db/psql/counts.go +++ b/internal/db/psql/counts.go @@ -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") } diff --git a/internal/db/psql/images.go b/internal/db/psql/images.go index a2b7710..49e2850 100644 --- a/internal/db/psql/images.go +++ b/internal/db/psql/images.go @@ -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{ diff --git a/internal/db/psql/listen.go b/internal/db/psql/listen.go index 0864643..301b6e3 100644 --- a/internal/db/psql/listen.go +++ b/internal/db/psql/listen.go @@ -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) } diff --git a/internal/db/psql/listen_activity.go b/internal/db/psql/listen_activity.go index 5f57f92..47b1a13 100644 --- a/internal/db/psql/listen_activity.go +++ b/internal/db/psql/listen_activity.go @@ -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 { diff --git a/internal/db/psql/merge.go b/internal/db/psql/merge.go index c7c46fe..d9e24b6 100644 --- a/internal/db/psql/merge.go +++ b/internal/db/psql/merge.go @@ -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{ diff --git a/internal/db/psql/psql.go b/internal/db/psql/psql.go index 2e52d94..0a917b5 100644 --- a/internal/db/psql/psql.go +++ b/internal/db/psql/psql.go @@ -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() diff --git a/internal/db/psql/search.go b/internal/db/psql/search.go index 675134b..e4ee39e 100644 --- a/internal/db/psql/search.go +++ b/internal/db/psql/search.go @@ -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 diff --git a/internal/db/psql/sessions.go b/internal/db/psql/sessions.go index d279121..ece1dc5 100644 --- a/internal/db/psql/sessions.go +++ b/internal/db/psql/sessions.go @@ -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{ diff --git a/internal/db/psql/top_albums.go b/internal/db/psql/top_albums.go index b44334d..f02f9e3 100644 --- a/internal/db/psql/top_albums.go +++ b/internal/db/psql/top_albums.go @@ -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) } diff --git a/internal/db/psql/top_artists.go b/internal/db/psql/top_artists.go index 980f89d..5f9680a 100644 --- a/internal/db/psql/top_artists.go +++ b/internal/db/psql/top_artists.go @@ -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) diff --git a/internal/db/psql/top_tracks.go b/internal/db/psql/top_tracks.go index 765b3a6..5e2d04d 100644 --- a/internal/db/psql/top_tracks.go +++ b/internal/db/psql/top_tracks.go @@ -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) } diff --git a/internal/db/psql/track.go b/internal/db/psql/track.go index 5d3961d..0841f36 100644 --- a/internal/db/psql/track.go +++ b/internal/db/psql/track.go @@ -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) } diff --git a/internal/db/psql/user.go b/internal/db/psql/user.go index cfc8dc7..33a8cf9 100644 --- a/internal/db/psql/user.go +++ b/internal/db/psql/user.go @@ -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 { diff --git a/internal/images/deezer.go b/internal/images/deezer.go index f3c7bae..8fb7b27 100644 --- a/internal/images/deezer.go +++ b/internal/images/deezer.go @@ -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") } diff --git a/internal/images/imagesrc.go b/internal/images/imagesrc.go index 4b65a66..e906c4d 100644 --- a/internal/images/imagesrc.go +++ b/internal/images/imagesrc.go @@ -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 } diff --git a/internal/importer/lastfm.go b/internal/importer/lastfm.go index d3e0028..763d7fa 100644 --- a/internal/importer/lastfm.go +++ b/internal/importer/lastfm.go @@ -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() diff --git a/internal/importer/listenbrainz.go b/internal/importer/listenbrainz.go index f8a8218..79d58d3 100644 --- a/internal/importer/listenbrainz.go +++ b/internal/importer/listenbrainz.go @@ -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() diff --git a/internal/importer/maloja.go b/internal/importer/maloja.go index 4265b98..8d7c041 100644 --- a/internal/importer/maloja.go +++ b/internal/importer/maloja.go @@ -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() } diff --git a/internal/importer/spotify.go b/internal/importer/spotify.go index 9e9073c..5594fc2 100644 --- a/internal/importer/spotify.go +++ b/internal/importer/spotify.go @@ -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() } diff --git a/internal/mbz/artist.go b/internal/mbz/artist.go index 8ebeb2e..f8e563a 100644 --- a/internal/mbz/artist.go +++ b/internal/mbz/artist.go @@ -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) diff --git a/internal/mbz/mbz.go b/internal/mbz/mbz.go index 46d516b..9e3f52e 100644 --- a/internal/mbz/mbz.go +++ b/internal/mbz/mbz.go @@ -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 diff --git a/internal/mbz/release.go b/internal/mbz/release.go index 594e576..0dcacfd 100644 --- a/internal/mbz/release.go +++ b/internal/mbz/release.go @@ -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 { diff --git a/internal/mbz/track.go b/internal/mbz/track.go index 6998a9f..a7d8a12 100644 --- a/internal/mbz/track.go +++ b/internal/mbz/track.go @@ -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 } diff --git a/internal/models/artist.go b/internal/models/artist.go index b515414..3501573 100644 --- a/internal/models/artist.go +++ b/internal/models/artist.go @@ -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 { diff --git a/internal/repository/artist.sql.go b/internal/repository/artist.sql.go index 3d01e1a..23a7a6f 100644 --- a/internal/repository/artist.sql.go +++ b/internal/repository/artist.sql.go @@ -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 } diff --git a/internal/repository/listen.sql.go b/internal/repository/listen.sql.go index d3567c3..fa23bae 100644 --- a/internal/repository/listen.sql.go +++ b/internal/repository/listen.sql.go @@ -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 diff --git a/internal/repository/models.go b/internal/repository/models.go index d1dc41f..df26d4d 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -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 { diff --git a/internal/repository/release.sql.go b/internal/repository/release.sql.go index 6d5cc68..11e8030 100644 --- a/internal/repository/release.sql.go +++ b/internal/repository/release.sql.go @@ -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 diff --git a/internal/repository/search.sql.go b/internal/repository/search.sql.go index 5e4e038..82a381b 100644 --- a/internal/repository/search.sql.go +++ b/internal/repository/search.sql.go @@ -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, diff --git a/internal/repository/track.sql.go b/internal/repository/track.sql.go index a31316b..7365225 100644 --- a/internal/repository/track.sql.go +++ b/internal/repository/track.sql.go @@ -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 +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index fdd2b80..0c75c69 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -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 + } +}