mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 21:48:18 -08:00
feat: v0.0.8
This commit is contained in:
parent
00e7782be2
commit
80b6f4deaa
66 changed files with 1559 additions and 916 deletions
21
CHANGELOG.md
21
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
|
||||
- Merge selections now function correctly when selecting an item while another is selected
|
||||
- Use anchor tags for top tracks and top albums
|
||||
- UI fixes
|
||||
|
||||
## Updates
|
||||
- Improved logging and error traces in logs
|
||||
|
||||
## Docs
|
||||
- Add KOITO_FETCH_IMAGES_DURING_IMPORT to config reference
|
||||
|
|
@ -226,6 +226,7 @@ type Artist = {
|
|||
listen_count: number
|
||||
musicbrainz_id: string
|
||||
time_listened: number
|
||||
is_primary: boolean
|
||||
}
|
||||
type Album = {
|
||||
id: number,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
|
|||
const selectItem = (title: string, id: number) => {
|
||||
if (selected === id) {
|
||||
setSelected(0)
|
||||
onSelect({id: id, title: title})
|
||||
onSelect({id: 0, title: ''})
|
||||
} else {
|
||||
setSelected(id)
|
||||
onSelect({id: id, title: title})
|
||||
|
|
|
|||
|
|
@ -35,92 +35,52 @@ export default function TopItemList<T extends Item>({ data, separators, type, cl
|
|||
|
||||
function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) {
|
||||
|
||||
const itemClasses = `flex items-center gap-2 hover:text-(--color-fg-secondary)`
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleItemClick = (type: string, id: number) => {
|
||||
navigate(`/${type.toLowerCase()}/${id}`);
|
||||
};
|
||||
|
||||
const handleArtistClick = (event: React.MouseEvent) => {
|
||||
// Stop the click from navigating to the album page
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
// Also stop keyboard events on the inner links from bubbling up
|
||||
const handleArtistKeyDown = (event: React.KeyboardEvent) => {
|
||||
event.stopPropagation();
|
||||
}
|
||||
const itemClasses = `flex items-center gap-2`
|
||||
|
||||
switch (type) {
|
||||
case "album": {
|
||||
const album = item as Album;
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleItemClick("album", album.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{fontSize: 12}}>
|
||||
<div
|
||||
className={itemClasses}
|
||||
onClick={() => handleItemClick("album", album.id)}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`View album: ${album.title}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<img src={imageUrl(album.image, "small")} alt={album.title} />
|
||||
<div>
|
||||
<div style={{fontSize: 12}} className={itemClasses}>
|
||||
<Link to={`/album/${album.id}`}>
|
||||
<img loading="lazy" src={imageUrl(album.image, "small")} alt={album.title} className="min-w-[48px]" />
|
||||
</Link>
|
||||
<div>
|
||||
<Link to={`/album/${album.id}`} className="hover:text-(--color-fg-secondary)">
|
||||
<span style={{fontSize: 14}}>{album.title}</span>
|
||||
<br />
|
||||
{album.is_various_artists ?
|
||||
<span className="color-fg-secondary">Various Artists</span>
|
||||
:
|
||||
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
|
||||
<ArtistLinks artists={album.artists ? [album.artists[0]] : [{id: 0, name: 'Unknown Artist'}]}/>
|
||||
</div>
|
||||
}
|
||||
<div className="color-fg-secondary">{album.listen_count} plays</div>
|
||||
</Link>
|
||||
<br />
|
||||
{album.is_various_artists ?
|
||||
<span className="color-fg-secondary">Various Artists</span>
|
||||
:
|
||||
<div>
|
||||
<ArtistLinks artists={album.artists ? [album.artists[0]] : [{id: 0, name: 'Unknown Artist'}]}/>
|
||||
</div>
|
||||
}
|
||||
<div className="color-fg-secondary">{album.listen_count} plays</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "track": {
|
||||
const track = item as Track;
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleItemClick("track", track.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{fontSize: 12}}>
|
||||
<div
|
||||
className={itemClasses}
|
||||
onClick={() => handleItemClick("track", track.id)}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`View track: ${track.title}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<img src={imageUrl(track.image, "small")} alt={track.title} />
|
||||
<div style={{fontSize: 12}} className={itemClasses}>
|
||||
<Link to={`/track/${track.id}`}>
|
||||
<img loading="lazy" src={imageUrl(track.image, "small")} alt={track.title} className="min-w-[48px]" />
|
||||
</Link>
|
||||
<div>
|
||||
<span style={{fontSize: 14}}>{track.title}</span>
|
||||
<Link to={`/track/${track.id}`} className="hover:text-(--color-fg-secondary)">
|
||||
<span style={{fontSize: 14}}>{track.title}</span>
|
||||
</Link>
|
||||
<br />
|
||||
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
|
||||
<div>
|
||||
<ArtistLinks artists={track.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
|
||||
</div>
|
||||
<div className="color-fg-secondary">{track.listen_count} plays</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -128,12 +88,12 @@ function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artis
|
|||
const artist = item as Artist;
|
||||
return (
|
||||
<div style={{fontSize: 12}}>
|
||||
<Link className={itemClasses+' mt-1 mb-[6px]'} to={`/artist/${artist.id}`}>
|
||||
<img src={imageUrl(artist.image, "small")} alt={artist.name} />
|
||||
<div>
|
||||
<span style={{fontSize: 14}}>{artist.name}</span>
|
||||
<div className="color-fg-secondary">{artist.listen_count} plays</div>
|
||||
</div>
|
||||
<Link className={itemClasses+' mt-1 mb-[6px] hover:text-(--color-fg-secondary)'} to={`/artist/${artist.id}`}>
|
||||
<img loading="lazy" src={imageUrl(artist.image, "small")} alt={artist.name} className="min-w-[48px]" />
|
||||
<div>
|
||||
<span style={{fontSize: 14}}>{artist.name}</span>
|
||||
<div className="color-fg-secondary">{artist.listen_count} plays</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createAlias, deleteAlias, getAliases, getAlbum, setPrimaryAlias, type Album, type Alias } from "api/api";
|
||||
import { Modal } from "./Modal";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api";
|
||||
import { Modal } from "../Modal";
|
||||
import { AsyncButton } from "../../AsyncButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trash } from "lucide-react";
|
||||
import SetVariousArtists from "./SetVariousArtist";
|
||||
import SetPrimaryArtist from "./SetPrimaryArtist";
|
||||
|
||||
interface Props {
|
||||
type: string
|
||||
|
|
@ -18,7 +19,6 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
|
|||
const [loading, setLoading ] = useState(false)
|
||||
const [err, setError ] = useState<string>()
|
||||
const [displayData, setDisplayData] = useState<Alias[]>([])
|
||||
const [variousArtists, setVariousArtists] = useState(false)
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
|
|
@ -125,7 +125,10 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
{ type.toLowerCase() === "album" &&
|
||||
<>
|
||||
<SetVariousArtists id={id} />
|
||||
<SetPrimaryArtist id={id} type="album" />
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</Modal>
|
||||
99
client/app/components/modals/EditModal/SetPrimaryArtist.tsx
Normal file
99
client/app/components/modals/EditModal/SetPrimaryArtist.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getAlbum, type Artist } from "api/api";
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
interface Props {
|
||||
id: number
|
||||
type: string
|
||||
}
|
||||
|
||||
export default function SetPrimaryArtist({ id, type }: Props) {
|
||||
const [err, setErr] = useState('')
|
||||
const [primary, setPrimary] = useState<Artist>()
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'get-artists-'+type.toLowerCase(),
|
||||
{
|
||||
id: id
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
return fetch('/apis/web/v1/artists?'+type.toLowerCase()+'_id='+id).then(r => r.json()) as Promise<Artist[]>;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
for (let a of data) {
|
||||
if (a.is_primary) {
|
||||
setPrimary(a)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="error">Error: {error.message}</p>
|
||||
)
|
||||
}
|
||||
if (isPending) {
|
||||
return (
|
||||
<p>Loading...</p>
|
||||
)
|
||||
}
|
||||
|
||||
const updatePrimary = (artist: number, val: boolean) => {
|
||||
setErr('');
|
||||
setSuccess('');
|
||||
fetch(`/apis/web/v1/artists/primary?artist_id=${artist}&${type.toLowerCase()}_id=${id}&is_primary=${val}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
})
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
setSuccess('successfully updated primary artists');
|
||||
} else {
|
||||
r.json().then(r => setErr(r.error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h2>Set Primary Artist</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<select
|
||||
name="mark-various-artists"
|
||||
id="mark-various-artists"
|
||||
className="w-60 px-3 py-2 rounded-md"
|
||||
value={primary?.name || ""}
|
||||
onChange={(e) => {
|
||||
for (let a of data) {
|
||||
if (a.name === e.target.value) {
|
||||
setPrimary(a);
|
||||
updatePrimary(a.id, true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select an artist
|
||||
</option>
|
||||
{data.map((a) => (
|
||||
<option key={a.id} value={a.name}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{err && <p className="error">{err}</p>}
|
||||
{success && <p className="success">{success}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ export default function SetVariousArtists({ id }: Props) {
|
|||
<option value="false">False</option>
|
||||
</select>
|
||||
{err && <p className="error">{err}</p>}
|
||||
{success && <p className="success">{success}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -34,15 +34,11 @@ export default function MergeModal(props: Props) {
|
|||
}
|
||||
|
||||
const toggleSelect = ({title, id}: {title: string, id: number}) => {
|
||||
if (mergeTarget.id === 0) {
|
||||
setMergeTarget({title: title, id: id})
|
||||
} else {
|
||||
setMergeTarget({title:"", id: 0})
|
||||
}
|
||||
setMergeTarget({title: title, id: id})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log(mergeTarget)
|
||||
console.log("mergeTarget",mergeTarget)
|
||||
}, [mergeTarget])
|
||||
|
||||
const doMerge = () => {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default function Home() {
|
|||
<TopArtists period={period} limit={homeItems} />
|
||||
<TopAlbums period={period} limit={homeItems} />
|
||||
<TopTracks period={period} limit={homeItems} />
|
||||
<LastPlays limit={Math.floor(homeItems * 2.5)} />
|
||||
<LastPlays limit={Math.floor(homeItems * 2.7)} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { useAppContext } from "~/providers/AppProvider";
|
|||
import MergeModal from "~/components/modals/MergeModal";
|
||||
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
|
||||
import DeleteModal from "~/components/modals/DeleteModal";
|
||||
import RenameModal from "~/components/modals/EditModal";
|
||||
import EditModal from "~/components/modals/EditModal";
|
||||
import RenameModal from "~/components/modals/EditModal/EditModal";
|
||||
import EditModal from "~/components/modals/EditModal/EditModal";
|
||||
|
||||
export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise<Response>
|
||||
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
|
||||
|
|
@ -69,9 +69,9 @@ export default function MediaLayout(props: Props) {
|
|||
content={title}
|
||||
/>
|
||||
<div className="w-19/20 mx-auto pt-12">
|
||||
<div className="flex gap-8 flex-wrap relative">
|
||||
<div className="flex gap-8 flex-wrap md:flex-nowrap relative">
|
||||
<div className="flex flex-col justify-around">
|
||||
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="md:w-sm w-[220px] h-auto shadow-(--color-shadow) shadow-lg" />
|
||||
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="md:min-w-[385px] w-[220px] h-auto shadow-(--color-shadow) shadow-lg" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<h3>{props.type}</h3>
|
||||
|
|
|
|||
48
db/migrations/000003_add_primary_artist.sql
Normal file
48
db/migrations/000003_add_primary_artist.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
SELECT 'up SQL query';
|
||||
-- +goose StatementEnd
|
||||
ALTER TABLE artist_tracks
|
||||
ADD COLUMN is_primary boolean NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE artist_releases
|
||||
ADD COLUMN is_primary boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose StatementBegin
|
||||
CREATE FUNCTION get_artists_for_release(release_id INTEGER)
|
||||
RETURNS JSONB AS $$
|
||||
SELECT json_agg(
|
||||
jsonb_build_object('id', a.id, 'name', a.name)
|
||||
ORDER BY ar.is_primary DESC, a.name
|
||||
)
|
||||
FROM artist_releases ar
|
||||
JOIN artists_with_name a ON a.id = ar.artist_id
|
||||
WHERE ar.release_id = $1;
|
||||
$$ LANGUAGE sql STABLE;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose StatementBegin
|
||||
CREATE FUNCTION get_artists_for_track(track_id INTEGER)
|
||||
RETURNS JSONB AS $$
|
||||
SELECT json_agg(
|
||||
jsonb_build_object('id', a.id, 'name', a.name)
|
||||
ORDER BY at.is_primary DESC, a.name
|
||||
)
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = $1;
|
||||
$$ LANGUAGE sql STABLE;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
SELECT 'down SQL query';
|
||||
-- +goose StatementEnd
|
||||
ALTER TABLE artist_tracks
|
||||
DROP COLUMN is_primary;
|
||||
|
||||
ALTER TABLE artist_releases
|
||||
DROP COLUMN is_primary;
|
||||
|
||||
DROP FUNCTION IF EXISTS get_artists_for_release(INTEGER);
|
||||
DROP FUNCTION IF EXISTS get_artists_for_track(INTEGER);
|
||||
|
|
@ -14,22 +14,24 @@ GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
|
|||
|
||||
-- name: GetTrackArtists :many
|
||||
SELECT
|
||||
a.*
|
||||
a.*,
|
||||
at.is_primary as is_primary
|
||||
FROM artists_with_name a
|
||||
LEFT JOIN artist_tracks at ON a.id = at.artist_id
|
||||
WHERE at.track_id = $1
|
||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
|
||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, at.is_primary;
|
||||
|
||||
-- name: GetArtistByImage :one
|
||||
SELECT * FROM artists WHERE image = $1 LIMIT 1;
|
||||
|
||||
-- name: GetReleaseArtists :many
|
||||
SELECT
|
||||
a.*
|
||||
a.*,
|
||||
ar.is_primary as is_primary
|
||||
FROM artists_with_name a
|
||||
LEFT JOIN artist_releases ar ON a.id = ar.artist_id
|
||||
WHERE ar.release_id = $1
|
||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name;
|
||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, ar.is_primary;
|
||||
|
||||
-- name: GetArtistByName :one
|
||||
WITH artist_with_aliases AS (
|
||||
|
|
|
|||
|
|
@ -8,12 +8,7 @@ SELECT
|
|||
l.*,
|
||||
t.title AS track_title,
|
||||
t.release_id AS release_id,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
|
|
@ -25,12 +20,7 @@ SELECT
|
|||
l.*,
|
||||
t.title AS track_title,
|
||||
t.release_id AS release_id,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
JOIN artist_tracks at ON t.id = at.track_id
|
||||
|
|
@ -44,12 +34,7 @@ SELECT
|
|||
l.*,
|
||||
t.title AS track_title,
|
||||
t.release_id AS release_id,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
|
|
@ -62,12 +47,7 @@ SELECT
|
|||
l.*,
|
||||
t.title AS track_title,
|
||||
t.release_id AS release_id,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
|
|
|
|||
|
|
@ -33,12 +33,7 @@ LIMIT 1;
|
|||
SELECT
|
||||
r.*,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
||||
WHERE ar.release_id = r.id
|
||||
) AS artists
|
||||
get_artists_for_release(r.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN releases_with_title r ON t.release_id = r.id
|
||||
|
|
@ -53,12 +48,7 @@ LIMIT $3 OFFSET $4;
|
|||
SELECT
|
||||
r.*,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
||||
WHERE ar.release_id = r.id
|
||||
) AS artists
|
||||
get_artists_for_release(r.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN releases_with_title r ON t.release_id = r.id
|
||||
|
|
@ -88,12 +78,7 @@ ON CONFLICT DO NOTHING;
|
|||
-- name: GetReleasesWithoutImages :many
|
||||
SELECT
|
||||
r.*,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON a.id = ar.artist_id
|
||||
WHERE ar.release_id = r.id
|
||||
) AS artists
|
||||
get_artists_for_release(r.id) AS artists
|
||||
FROM releases_with_title r
|
||||
WHERE r.image IS NULL
|
||||
AND r.id > $2
|
||||
|
|
@ -108,6 +93,10 @@ WHERE id = $1;
|
|||
UPDATE releases SET various_artists = $2
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpdateReleasePrimaryArtist :exec
|
||||
UPDATE artist_releases SET is_primary = $3
|
||||
WHERE artist_id = $1 AND release_id = $2;
|
||||
|
||||
-- name: UpdateReleaseImage :exec
|
||||
UPDATE releases SET image = $2, image_source = $3
|
||||
WHERE id = $1;
|
||||
|
|
|
|||
|
|
@ -42,12 +42,7 @@ SELECT
|
|||
ranked.release_id,
|
||||
ranked.image,
|
||||
ranked.score,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = ranked.id
|
||||
) AS artists
|
||||
get_artists_for_track(ranked.id) AS artists
|
||||
FROM (
|
||||
SELECT
|
||||
t.id,
|
||||
|
|
@ -74,12 +69,7 @@ SELECT
|
|||
ranked.release_id,
|
||||
ranked.image,
|
||||
ranked.score,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = ranked.id
|
||||
) AS artists
|
||||
get_artists_for_track(ranked.id) AS artists
|
||||
FROM (
|
||||
SELECT
|
||||
t.id,
|
||||
|
|
@ -106,12 +96,7 @@ SELECT
|
|||
ranked.image,
|
||||
ranked.various_artists,
|
||||
ranked.score,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
||||
WHERE ar.release_id = ranked.id
|
||||
) AS artists
|
||||
get_artists_for_release(ranked.id) AS artists
|
||||
FROM (
|
||||
SELECT
|
||||
r.id,
|
||||
|
|
@ -137,12 +122,7 @@ SELECT
|
|||
ranked.image,
|
||||
ranked.various_artists,
|
||||
ranked.score,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
||||
WHERE ar.release_id = ranked.id
|
||||
) AS artists
|
||||
get_artists_for_release(ranked.id) AS artists
|
||||
FROM (
|
||||
SELECT
|
||||
r.id,
|
||||
|
|
|
|||
|
|
@ -43,12 +43,7 @@ SELECT
|
|||
t.release_id,
|
||||
r.image,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
JOIN releases r ON t.release_id = r.id
|
||||
|
|
@ -65,12 +60,7 @@ SELECT
|
|||
t.release_id,
|
||||
r.image,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at2
|
||||
JOIN artists_with_name a ON a.id = at2.artist_id
|
||||
WHERE at2.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
JOIN releases r ON t.release_id = r.id
|
||||
|
|
@ -89,12 +79,7 @@ SELECT
|
|||
t.release_id,
|
||||
r.image,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at2
|
||||
JOIN artists_with_name a ON a.id = at2.artist_id
|
||||
WHERE at2.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
JOIN releases r ON t.release_id = r.id
|
||||
|
|
@ -135,5 +120,9 @@ WHERE id = $1;
|
|||
UPDATE tracks SET release_id = $2
|
||||
WHERE release_id = $1;
|
||||
|
||||
-- name: UpdateTrackPrimaryArtist :exec
|
||||
UPDATE artist_tracks SET is_primary = $3
|
||||
WHERE artist_id = $1 AND track_id = $2;
|
||||
|
||||
-- name: DeleteTrack :exec
|
||||
DELETE FROM tracks WHERE id = $1;
|
||||
|
|
@ -70,6 +70,9 @@ Koito is configured using **environment variables**. This is the full list of co
|
|||
- Description: A unix timestamp. If an imported listen has a timestamp after this, it will be discarded.
|
||||
##### KOITO_IMPORT_AFTER_UNIX
|
||||
- Description: A unix timestamp. If an imported listen has a timestamp before this, it will be discarded.
|
||||
##### KOITO_FETCH_IMAGES_DURING_IMPORT
|
||||
- Default: `false`
|
||||
- Description: When true, images will be downloaded and cached during imports.
|
||||
##### KOITO_CORS_ALLOWED_ORIGINS
|
||||
- Default: No CORS policy
|
||||
- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins.
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
|
|
@ -40,44 +39,43 @@ func GetAliasesHandler(store db.DB) http.HandlerFunc {
|
|||
if artistIDStr != "" {
|
||||
artistID, err := strconv.Atoi(artistIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Invalid artist id")
|
||||
l.Debug().AnErr("error", err).Msg("GetAliasesHandler: Invalid artist id")
|
||||
utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
aliases, err = store.GetAllArtistAliases(ctx, int32(artistID))
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Failed to get artist aliases")
|
||||
l.Err(err).Msg("GetAliasesHandler: Failed to get artist aliases")
|
||||
utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if albumIDStr != "" {
|
||||
albumID, err := strconv.Atoi(albumIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Invalid album id")
|
||||
l.Debug().AnErr("error", err).Msg("GetAliasesHandler: Invalid album id")
|
||||
utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
aliases, err = store.GetAllAlbumAliases(ctx, int32(albumID))
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Failed to get album aliases")
|
||||
l.Err(err).Msg("GetAliasesHandler: Failed to get album aliases")
|
||||
utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if trackIDStr != "" {
|
||||
trackID, err := strconv.Atoi(trackIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Invalid track id")
|
||||
l.Debug().AnErr("error", err).Msg("GetAliasesHandler: Invalid track id")
|
||||
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
aliases, err = store.GetAllTrackAliases(ctx, int32(trackID))
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("GetAliasesHandler: %w", err)).Msg("Failed to get track aliases")
|
||||
l.Err(err).Msg("GetAliasesHandler: Failed to get track aliases")
|
||||
utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, aliases)
|
||||
}
|
||||
}
|
||||
|
|
@ -88,7 +86,7 @@ func DeleteAliasHandler(store db.DB) http.HandlerFunc {
|
|||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
l.Debug().Msgf("DeleteAliasHandler: Got request with params: '%s'", r.URL.Query().Encode())
|
||||
l.Debug().Msg("DeleteAliasHandler: Got request")
|
||||
|
||||
// Parse query parameters
|
||||
artistIDStr := r.URL.Query().Get("artist_id")
|
||||
|
|
@ -97,52 +95,56 @@ func DeleteAliasHandler(store db.DB) http.HandlerFunc {
|
|||
alias := r.URL.Query().Get("alias")
|
||||
|
||||
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
|
||||
l.Debug().Msgf("DeleteAliasHandler: Request is missing required parameters")
|
||||
l.Debug().Msg("DeleteAliasHandler: Request is missing required parameters")
|
||||
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
|
||||
l.Debug().Msgf("DeleteAliasHandler: Request is has more than one of artist_id, album_id, and track_id")
|
||||
l.Debug().Msg("DeleteAliasHandler: Request has more than one of artist_id, album_id, and track_id")
|
||||
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if artistIDStr != "" {
|
||||
artistID, err := strconv.Atoi(artistIDStr)
|
||||
var artistID int
|
||||
artistID, err = strconv.Atoi(artistIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Invalid artist id")
|
||||
l.Debug().AnErr("error", err).Msg("DeleteAliasHandler: Invalid artist id")
|
||||
utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = store.DeleteArtistAlias(ctx, int32(artistID), alias)
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Failed to delete artist alias")
|
||||
l.Error().Err(err).Msg("DeleteAliasHandler: Failed to delete artist alias")
|
||||
utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if albumIDStr != "" {
|
||||
albumID, err := strconv.Atoi(albumIDStr)
|
||||
var albumID int
|
||||
albumID, err = strconv.Atoi(albumIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Invalid album id")
|
||||
l.Debug().AnErr("error", err).Msg("DeleteAliasHandler: Invalid album id")
|
||||
utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = store.DeleteAlbumAlias(ctx, int32(albumID), alias)
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Failed to delete album alias")
|
||||
l.Error().Err(err).Msg("DeleteAliasHandler: Failed to delete album alias")
|
||||
utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if trackIDStr != "" {
|
||||
trackID, err := strconv.Atoi(trackIDStr)
|
||||
var trackID int
|
||||
trackID, err = strconv.Atoi(trackIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Invalid track id")
|
||||
l.Debug().AnErr("error", err).Msg("DeleteAliasHandler: Invalid track id")
|
||||
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = store.DeleteTrackAlias(ctx, int32(trackID), alias)
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("DeleteAliasHandler: %w", err)).Msg("Failed to delete track alias")
|
||||
l.Error().Err(err).Msg("DeleteAliasHandler: Failed to delete track alias")
|
||||
utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -158,16 +160,18 @@ func CreateAliasHandler(store db.DB) http.HandlerFunc {
|
|||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
l.Debug().Msgf("CreateAliasHandler: Got request with params: '%s'", r.URL.Query().Encode())
|
||||
l.Debug().Msg("CreateAliasHandler: Got request")
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", err).Msg("CreateAliasHandler: Failed to parse form")
|
||||
utils.WriteError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
alias := r.FormValue("alias")
|
||||
if alias == "" {
|
||||
l.Debug().Msg("CreateAliasHandler: Alias parameter missing")
|
||||
utils.WriteError(w, "alias must be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
|
@ -176,53 +180,54 @@ func CreateAliasHandler(store db.DB) http.HandlerFunc {
|
|||
albumIDStr := r.URL.Query().Get("album_id")
|
||||
trackIDStr := r.URL.Query().Get("track_id")
|
||||
|
||||
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
|
||||
l.Debug().Msgf("CreateAliasHandler: Request is missing required parameters")
|
||||
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
|
||||
if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" {
|
||||
l.Debug().Msg("CreateAliasHandler: Missing ID parameter")
|
||||
utils.WriteError(w, "artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
|
||||
l.Debug().Msgf("CreateAliasHandler: Request is has more than one of artist_id, album_id, and track_id")
|
||||
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
|
||||
l.Debug().Msg("CreateAliasHandler: Multiple ID parameters provided")
|
||||
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var id int
|
||||
if artistIDStr != "" {
|
||||
artistID, err := strconv.Atoi(artistIDStr)
|
||||
id, err = strconv.Atoi(artistIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Invalid artist id")
|
||||
l.Debug().AnErr("error", err).Msg("CreateAliasHandler: Invalid artist id")
|
||||
utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = store.SaveArtistAliases(ctx, int32(artistID), []string{alias}, "Manual")
|
||||
err = store.SaveArtistAliases(ctx, int32(id), []string{alias}, "Manual")
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Failed to save artist alias")
|
||||
l.Error().Err(err).Msg("CreateAliasHandler: Failed to save artist alias")
|
||||
utils.WriteError(w, "failed to save alias", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if albumIDStr != "" {
|
||||
albumID, err := strconv.Atoi(albumIDStr)
|
||||
id, err = strconv.Atoi(albumIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Invalid album id")
|
||||
l.Debug().AnErr("error", err).Msg("CreateAliasHandler: Invalid album id")
|
||||
utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = store.SaveAlbumAliases(ctx, int32(albumID), []string{alias}, "Manual")
|
||||
err = store.SaveAlbumAliases(ctx, int32(id), []string{alias}, "Manual")
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Failed to save album alias")
|
||||
l.Error().Err(err).Msg("CreateAliasHandler: Failed to save album alias")
|
||||
utils.WriteError(w, "failed to save alias", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if trackIDStr != "" {
|
||||
trackID, err := strconv.Atoi(trackIDStr)
|
||||
id, err = strconv.Atoi(trackIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Invalid track id")
|
||||
l.Debug().AnErr("error", err).Msg("CreateAliasHandler: Invalid track id")
|
||||
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = store.SaveTrackAliases(ctx, int32(trackID), []string{alias}, "Manual")
|
||||
err = store.SaveTrackAliases(ctx, int32(id), []string{alias}, "Manual")
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("CreateAliasHandler: %w", err)).Msg("Failed to save track alias")
|
||||
l.Error().Err(err).Msg("CreateAliasHandler: Failed to save track alias")
|
||||
utils.WriteError(w, "failed to save alias", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
@ -238,7 +243,7 @@ func SetPrimaryAliasHandler(store db.DB) http.HandlerFunc {
|
|||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
l.Debug().Msgf("SetPrimaryAliasHandler: Got request with params: '%s'", r.URL.Query().Encode())
|
||||
l.Debug().Msg("SetPrimaryAliasHandler: Got request")
|
||||
|
||||
// Parse query parameters
|
||||
artistIDStr := r.URL.Query().Get("artist_id")
|
||||
|
|
@ -246,53 +251,60 @@ func SetPrimaryAliasHandler(store db.DB) http.HandlerFunc {
|
|||
trackIDStr := r.URL.Query().Get("track_id")
|
||||
alias := r.URL.Query().Get("alias")
|
||||
|
||||
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
|
||||
l.Debug().Msgf("SetPrimaryAliasHandler: Request is missing required parameters")
|
||||
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
|
||||
if alias == "" {
|
||||
l.Debug().Msg("SetPrimaryAliasHandler: Missing alias parameter")
|
||||
utils.WriteError(w, "alias must be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" {
|
||||
l.Debug().Msg("SetPrimaryAliasHandler: Missing ID parameter")
|
||||
utils.WriteError(w, "artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
|
||||
l.Debug().Msgf("SetPrimaryAliasHandler: Request is has more than one of artist_id, album_id, and track_id")
|
||||
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
|
||||
l.Debug().Msg("SetPrimaryAliasHandler: Multiple ID parameters provided")
|
||||
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var id int
|
||||
var err error
|
||||
if artistIDStr != "" {
|
||||
artistID, err := strconv.Atoi(artistIDStr)
|
||||
id, err = strconv.Atoi(artistIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Invalid artist id")
|
||||
l.Debug().AnErr("error", err).Msg("SetPrimaryAliasHandler: Invalid artist id")
|
||||
utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = store.SetPrimaryArtistAlias(ctx, int32(artistID), alias)
|
||||
err = store.SetPrimaryArtistAlias(ctx, int32(id), alias)
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Failed to set artist primary alias")
|
||||
l.Error().Err(err).Msg("SetPrimaryAliasHandler: Failed to set artist primary alias")
|
||||
utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if albumIDStr != "" {
|
||||
albumID, err := strconv.Atoi(albumIDStr)
|
||||
id, err = strconv.Atoi(albumIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Invalid album id")
|
||||
l.Debug().AnErr("error", err).Msg("SetPrimaryAliasHandler: Invalid album id")
|
||||
utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = store.SetPrimaryAlbumAlias(ctx, int32(albumID), alias)
|
||||
err = store.SetPrimaryAlbumAlias(ctx, int32(id), alias)
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Failed to set album primary alias")
|
||||
l.Error().Err(err).Msg("SetPrimaryAliasHandler: Failed to set album primary alias")
|
||||
utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if trackIDStr != "" {
|
||||
trackID, err := strconv.Atoi(trackIDStr)
|
||||
id, err = strconv.Atoi(trackIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Invalid track id")
|
||||
l.Debug().AnErr("error", err).Msg("SetPrimaryAliasHandler: Invalid track id")
|
||||
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = store.SetPrimaryTrackAlias(ctx, int32(trackID), alias)
|
||||
err = store.SetPrimaryTrackAlias(ctx, int32(id), alias)
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("SetPrimaryAliasHandler: %w", err)).Msg("Failed to set track primary alias")
|
||||
l.Error().Err(err).Msg("SetPrimaryAliasHandler: Failed to set track primary alias")
|
||||
utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
|
|
@ -16,45 +15,47 @@ func GenerateApiKeyHandler(store db.DB) http.HandlerFunc {
|
|||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
l.Debug().Msgf("GenerateApiKeyHandler: Received request with params: '%s'", r.URL.Query().Encode())
|
||||
l.Debug().Msg("GenerateApiKeyHandler: Received request")
|
||||
|
||||
user := middleware.GetUserFromContext(ctx)
|
||||
if user == nil {
|
||||
l.Debug().Msg("GenerateApiKeyHandler: Invalid user retrieved from context")
|
||||
l.Debug().Msg("GenerateApiKeyHandler: Invalid user context")
|
||||
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
r.ParseForm()
|
||||
if err := r.ParseForm(); err != nil {
|
||||
l.Debug().AnErr("error", err).Msg("GenerateApiKeyHandler: Failed to parse form")
|
||||
utils.WriteError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
label := r.FormValue("label")
|
||||
if label == "" {
|
||||
l.Debug().Msg("GenerateApiKeyHandler: Request rejected due to missing label")
|
||||
l.Debug().Msg("GenerateApiKeyHandler: Missing label parameter")
|
||||
utils.WriteError(w, "label is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, err := utils.GenerateRandomString(48)
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("GenerateApiKeyHandler: %w", err)).Msg("Failed to generate API key")
|
||||
l.Error().Err(err).Msg("GenerateApiKeyHandler: Failed to generate API key")
|
||||
utils.WriteError(w, "failed to generate api key", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
opts := db.SaveApiKeyOpts{
|
||||
key, err := store.SaveApiKey(ctx, db.SaveApiKeyOpts{
|
||||
UserID: user.ID,
|
||||
Key: apiKey,
|
||||
Label: label,
|
||||
}
|
||||
l.Debug().Msgf("GenerateApiKeyHandler: Saving API key with options: %+v", opts)
|
||||
|
||||
key, err := store.SaveApiKey(ctx, opts)
|
||||
})
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("GenerateApiKeyHandler: %w", err)).Msg("Failed to save API key")
|
||||
l.Error().Err(err).Msg("GenerateApiKeyHandler: Failed to save API key")
|
||||
utils.WriteError(w, "failed to save api key", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug().Msgf("GenerateApiKeyHandler: Successfully saved API key with ID: %d", key.ID)
|
||||
l.Debug().Msgf("GenerateApiKeyHandler: Successfully generated API key ID %d", key.ID)
|
||||
utils.WriteJSON(w, http.StatusCreated, key)
|
||||
}
|
||||
}
|
||||
|
|
@ -64,39 +65,36 @@ func DeleteApiKeyHandler(store db.DB) http.HandlerFunc {
|
|||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
l.Debug().Msgf("DeleteApiKeyHandler: Received request with params: '%s'", r.URL.Query().Encode())
|
||||
l.Debug().Msg("DeleteApiKeyHandler: Received request")
|
||||
|
||||
user := middleware.GetUserFromContext(ctx)
|
||||
if user == nil {
|
||||
l.Debug().Msg("DeleteApiKeyHandler: User could not be verified (context user is nil)")
|
||||
l.Debug().Msg("DeleteApiKeyHandler: Invalid user context")
|
||||
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
if idStr == "" {
|
||||
l.Debug().Msg("DeleteApiKeyHandler: Request rejected due to missing ID")
|
||||
l.Debug().Msg("DeleteApiKeyHandler: Missing id parameter")
|
||||
utils.WriteError(w, "id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, err := strconv.Atoi(idStr)
|
||||
apiKeyID, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("DeleteApiKeyHandler: %w", err)).Msg("Invalid API key ID")
|
||||
utils.WriteError(w, "id is invalid", http.StatusBadRequest)
|
||||
l.Debug().AnErr("error", err).Msg("DeleteApiKeyHandler: Invalid API key ID")
|
||||
utils.WriteError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug().Msgf("DeleteApiKeyHandler: Deleting API key with ID: %d", apiKey)
|
||||
|
||||
err = store.DeleteApiKey(ctx, int32(apiKey))
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("DeleteApiKeyHandler: %w", err)).Msg("Failed to delete API key")
|
||||
if err := store.DeleteApiKey(ctx, int32(apiKeyID)); err != nil {
|
||||
l.Error().Err(err).Msg("DeleteApiKeyHandler: Failed to delete API key")
|
||||
utils.WriteError(w, "failed to delete api key", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug().Msgf("DeleteApiKeyHandler: Successfully deleted API key with ID: %d", apiKey)
|
||||
l.Debug().Msgf("DeleteApiKeyHandler: Successfully deleted API key ID %d", apiKeyID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
|
@ -106,25 +104,23 @@ func GetApiKeysHandler(store db.DB) http.HandlerFunc {
|
|||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
l.Debug().Msgf("GetApiKeysHandler: Received request with params: '%s'", r.URL.Query().Encode())
|
||||
l.Debug().Msg("GetApiKeysHandler: Received request")
|
||||
|
||||
user := middleware.GetUserFromContext(ctx)
|
||||
if user == nil {
|
||||
l.Debug().Msg("GetApiKeysHandler: Invalid user retrieved from context")
|
||||
l.Debug().Msg("GetApiKeysHandler: Invalid user context")
|
||||
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug().Msgf("GetApiKeysHandler: Retrieving API keys for user ID: %d", user.ID)
|
||||
|
||||
apiKeys, err := store.GetApiKeysByUserID(ctx, user.ID)
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("GetApiKeysHandler: %w", err)).Msg("Failed to retrieve API keys")
|
||||
l.Error().Err(err).Msg("GetApiKeysHandler: Failed to retrieve API keys")
|
||||
utils.WriteError(w, "failed to retrieve api keys", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug().Msgf("GetApiKeysHandler: Successfully retrieved %d API keys for user ID: %d", len(apiKeys), user.ID)
|
||||
l.Debug().Msgf("GetApiKeysHandler: Retrieved %d API keys", len(apiKeys))
|
||||
utils.WriteJSON(w, http.StatusOK, apiKeys)
|
||||
}
|
||||
}
|
||||
|
|
@ -134,45 +130,42 @@ func UpdateApiKeyLabelHandler(store db.DB) http.HandlerFunc {
|
|||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
l.Debug().Msg("UpdateApiKeyLabelHandler: Received request to update API key label")
|
||||
l.Debug().Msg("UpdateApiKeyLabelHandler: Received request")
|
||||
|
||||
user := middleware.GetUserFromContext(ctx)
|
||||
if user == nil {
|
||||
l.Debug().Msg("UpdateApiKeyLabelHandler: Unauthorized request (user context is nil)")
|
||||
l.Debug().Msg("UpdateApiKeyLabelHandler: Invalid user context")
|
||||
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
if idStr == "" {
|
||||
l.Debug().Msg("UpdateApiKeyLabelHandler: Missing API key ID in request")
|
||||
l.Debug().Msg("UpdateApiKeyLabelHandler: Missing id parameter")
|
||||
utils.WriteError(w, "id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
apiKeyID, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", fmt.Errorf("UpdateApiKeyLabelHandler: %w", err)).Msg("Invalid API key ID")
|
||||
utils.WriteError(w, "id is invalid", http.StatusBadRequest)
|
||||
l.Debug().AnErr("error", err).Msg("UpdateApiKeyLabelHandler: Invalid API key ID")
|
||||
utils.WriteError(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
label := r.FormValue("label")
|
||||
if label == "" {
|
||||
l.Debug().Msg("UpdateApiKeyLabelHandler: Missing label in request")
|
||||
l.Debug().Msg("UpdateApiKeyLabelHandler: Missing label parameter")
|
||||
utils.WriteError(w, "label is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug().Msgf("UpdateApiKeyLabelHandler: Updating label for API key ID %d", apiKeyID)
|
||||
|
||||
err = store.UpdateApiKeyLabel(ctx, db.UpdateApiKeyLabelOpts{
|
||||
if err := store.UpdateApiKeyLabel(ctx, db.UpdateApiKeyLabelOpts{
|
||||
UserID: user.ID,
|
||||
ID: int32(apiKeyID),
|
||||
Label: label,
|
||||
})
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("UpdateApiKeyLabelHandler: %w", err)).Msg("Failed to update API key label")
|
||||
}); err != nil {
|
||||
l.Error().Err(err).Msg("UpdateApiKeyLabelHandler: Failed to update API key label")
|
||||
utils.WriteError(w, "failed to update api key label", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
156
engine/handlers/artists.go
Normal file
156
engine/handlers/artists.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gabehf/koito/internal/db"
|
||||
"github.com/gabehf/koito/internal/logger"
|
||||
"github.com/gabehf/koito/internal/models"
|
||||
"github.com/gabehf/koito/internal/utils"
|
||||
)
|
||||
|
||||
func SetPrimaryArtistHandler(store db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// sets the primary alias for albums, artists, and tracks
|
||||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
l.Debug().Msg("SetPrimaryArtistHandler: Got request")
|
||||
|
||||
r.ParseForm()
|
||||
|
||||
// Parse query parameters
|
||||
artistIDStr := r.FormValue("artist_id")
|
||||
albumIDStr := r.FormValue("album_id")
|
||||
trackIDStr := r.FormValue("track_id")
|
||||
isPrimaryStr := r.FormValue("is_primary")
|
||||
|
||||
l.Debug().Str("query", r.Form.Encode()).Msg("Recieved form")
|
||||
|
||||
if artistIDStr == "" {
|
||||
l.Debug().Msg("SetPrimaryArtistHandler: artist_id must be provided")
|
||||
utils.WriteError(w, "artist_id must be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if isPrimaryStr == "" {
|
||||
l.Debug().Msg("SetPrimaryArtistHandler: is_primary must be provided")
|
||||
utils.WriteError(w, "is_primary must be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
primary, ok := utils.ParseBool(isPrimaryStr)
|
||||
if !ok {
|
||||
l.Debug().Msg("SetPrimaryArtistHandler: is_primary must be either true or false")
|
||||
utils.WriteError(w, "is_primary must be either true or false", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
artistId, err := strconv.Atoi(artistIDStr)
|
||||
if err != nil {
|
||||
l.Debug().Msg("SetPrimaryArtistHandler: artist_id is invalid")
|
||||
utils.WriteError(w, "artist_id is invalid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if albumIDStr == "" && trackIDStr == "" {
|
||||
l.Debug().Msg("SetPrimaryArtistHandler: Missing album or track id parameter")
|
||||
utils.WriteError(w, "album_id or track_id must be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if utils.MoreThanOneString(albumIDStr, trackIDStr) {
|
||||
l.Debug().Msg("SetPrimaryArtistHandler: Multiple ID parameters provided")
|
||||
utils.WriteError(w, "only one of album_id or track_id can be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if albumIDStr != "" {
|
||||
id, err := strconv.Atoi(albumIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", err).Msg("SetPrimaryArtistHandler: Invalid album id")
|
||||
utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = store.SetPrimaryAlbumArtist(ctx, int32(id), int32(artistId), primary)
|
||||
if err != nil {
|
||||
l.Error().Err(err).Msg("SetPrimaryArtistHandler: Failed to set album primary alias")
|
||||
utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if trackIDStr != "" {
|
||||
id, err := strconv.Atoi(trackIDStr)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", err).Msg("SetPrimaryArtistHandler: Invalid track id")
|
||||
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = store.SetPrimaryTrackArtist(ctx, int32(id), int32(artistId), primary)
|
||||
if err != nil {
|
||||
l.Error().Err(err).Msg("SetPrimaryArtistHandler: Failed to set track primary alias")
|
||||
utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
func GetArtistsForItemHandler(store db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
l.Debug().Msg("GetArtistsForItemHandler: Received request to retrieve artists for item")
|
||||
|
||||
albumIDStr := r.URL.Query().Get("album_id")
|
||||
trackIDStr := r.URL.Query().Get("track_id")
|
||||
|
||||
if albumIDStr == "" && trackIDStr == "" {
|
||||
l.Debug().Msg("GetArtistsForItemHandler: Missing album or track ID parameter")
|
||||
utils.WriteError(w, "album_id or track_id must be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if utils.MoreThanOneString(albumIDStr, trackIDStr) {
|
||||
l.Debug().Msg("GetArtistsForItemHandler: Multiple ID parameters provided")
|
||||
utils.WriteError(w, "only one of album_id or track_id can be provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var artists []*models.Artist
|
||||
var err error
|
||||
|
||||
if albumIDStr != "" {
|
||||
albumID, convErr := strconv.Atoi(albumIDStr)
|
||||
if convErr != nil {
|
||||
l.Debug().AnErr("error", convErr).Msg("GetArtistsForItemHandler: Invalid album ID")
|
||||
utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug().Msgf("GetArtistsForItemHandler: Fetching artists for album ID %d", albumID)
|
||||
artists, err = store.GetArtistsForAlbum(ctx, int32(albumID))
|
||||
} else if trackIDStr != "" {
|
||||
trackID, convErr := strconv.Atoi(trackIDStr)
|
||||
if convErr != nil {
|
||||
l.Debug().AnErr("error", convErr).Msg("GetArtistsForItemHandler: Invalid track ID")
|
||||
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug().Msgf("GetArtistsForItemHandler: Fetching artists for track ID %d", trackID)
|
||||
artists, err = store.GetArtistsForTrack(ctx, int32(trackID))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
l.Err(err).Msg("GetArtistsForItemHandler: Failed to retrieve artists")
|
||||
utils.WriteError(w, "failed to retrieve artists", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug().Msg("GetArtistsForItemHandler: Successfully retrieved artists")
|
||||
utils.WriteJSON(w, http.StatusOK, artists)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,70 +18,62 @@ func LoginHandler(store db.DB) http.HandlerFunc {
|
|||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
l.Debug().Msg("LoginHandler: Received login request")
|
||||
l.Debug().Msg("LoginHandler: Received request")
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
l.Debug().Msg("LoginHandler: Failed to parse request form")
|
||||
utils.WriteError(w, "failed to parse request", http.StatusInternalServerError)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
l.Debug().AnErr("error", err).Msg("LoginHandler: Failed to parse form")
|
||||
utils.WriteError(w, "invalid request format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
if username == "" || password == "" {
|
||||
l.Debug().Msg("LoginHandler: Missing username or password")
|
||||
utils.WriteError(w, "username and password are required", http.StatusBadRequest)
|
||||
l.Debug().Msg("LoginHandler: Missing credentials")
|
||||
utils.WriteError(w, "username and password required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug().Msgf("LoginHandler: Searching for user with username '%s'", username)
|
||||
user, err := store.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("LoginHandler: Error searching for user in database")
|
||||
utils.WriteError(w, "internal server error", http.StatusInternalServerError)
|
||||
l.Error().Err(err).Msg("LoginHandler: Database error fetching user")
|
||||
utils.WriteError(w, "authentication failed", http.StatusInternalServerError)
|
||||
return
|
||||
} else if user == nil {
|
||||
l.Debug().Msg("LoginHandler: Username or password is incorrect")
|
||||
utils.WriteError(w, "username or password is incorrect", http.StatusBadRequest)
|
||||
}
|
||||
if user == nil {
|
||||
l.Debug().Msg("LoginHandler: User not found")
|
||||
utils.WriteError(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword(user.Password, []byte(password))
|
||||
if err != nil {
|
||||
l.Debug().Msg("LoginHandler: Password comparison failed")
|
||||
utils.WriteError(w, "username or password is incorrect", http.StatusBadRequest)
|
||||
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(password)); err != nil {
|
||||
l.Debug().Msg("LoginHandler: Invalid password")
|
||||
utils.WriteError(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
keepSignedIn := false
|
||||
expiresAt := time.Now().Add(1 * 24 * time.Hour)
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
if strings.ToLower(r.FormValue("remember_me")) == "true" {
|
||||
keepSignedIn = true
|
||||
expiresAt = time.Now().Add(30 * 24 * time.Hour)
|
||||
}
|
||||
|
||||
l.Debug().Msgf("LoginHandler: Creating session for user ID %d", user.ID)
|
||||
session, err := store.SaveSession(ctx, user.ID, expiresAt, keepSignedIn)
|
||||
session, err := store.SaveSession(ctx, user.ID, expiresAt, r.FormValue("remember_me") == "true")
|
||||
if err != nil {
|
||||
l.Err(err).Msg("LoginHandler: Failed to create session")
|
||||
utils.WriteError(w, "failed to create session", http.StatusInternalServerError)
|
||||
l.Error().Err(err).Msg("LoginHandler: Failed to create session")
|
||||
utils.WriteError(w, "authentication failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cookie := &http.Cookie{
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "koito_session",
|
||||
Value: session.ID.String(),
|
||||
Expires: expiresAt,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
}
|
||||
})
|
||||
|
||||
if keepSignedIn {
|
||||
cookie.Expires = expiresAt
|
||||
}
|
||||
|
||||
l.Debug().Msgf("LoginHandler: Session created successfully for user ID %d", user.ID)
|
||||
http.SetCookie(w, cookie)
|
||||
l.Debug().Msgf("LoginHandler: User %d authenticated", user.ID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
|
@ -91,34 +83,27 @@ func LogoutHandler(store db.DB) http.HandlerFunc {
|
|||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
l.Debug().Msg("LogoutHandler: Received logout request")
|
||||
l.Debug().Msg("LogoutHandler: Received request")
|
||||
|
||||
cookie, err := r.Cookie("koito_session")
|
||||
if err == nil {
|
||||
l.Debug().Msg("LogoutHandler: Found session cookie")
|
||||
sid, err := uuid.Parse(cookie.Value)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("error", err).Msg("LogoutHandler: Invalid session cookie")
|
||||
utils.WriteError(w, "session cookie is invalid", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
l.Debug().Msgf("LogoutHandler: Deleting session with ID %s", sid)
|
||||
err = store.DeleteSession(ctx, sid)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("LogoutHandler: Failed to delete session")
|
||||
utils.WriteError(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
l.Debug().AnErr("error", err).Msg("LogoutHandler: Invalid session ID")
|
||||
} else if err := store.DeleteSession(ctx, sid); err != nil {
|
||||
l.Error().Err(err).Msg("LogoutHandler: Failed to delete session")
|
||||
}
|
||||
}
|
||||
|
||||
l.Debug().Msg("LogoutHandler: Clearing session cookie")
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "koito_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1, // expire immediately
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
l.Debug().Msg("LogoutHandler: Session terminated")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
|
@ -128,16 +113,17 @@ func MeHandler(store db.DB) http.HandlerFunc {
|
|||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
l.Debug().Msg("MeHandler: Received request to retrieve user information")
|
||||
u := middleware.GetUserFromContext(ctx)
|
||||
if u == nil {
|
||||
l.Debug().Msg("MeHandler: Invalid user retrieved from context")
|
||||
l.Debug().Msg("MeHandler: Received request")
|
||||
|
||||
user := middleware.GetUserFromContext(ctx)
|
||||
if user == nil {
|
||||
l.Debug().Msg("MeHandler: Unauthorized access")
|
||||
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug().Msgf("MeHandler: Successfully retrieved user with ID %d", u.ID)
|
||||
utils.WriteJSON(w, http.StatusOK, u)
|
||||
l.Debug().Msgf("MeHandler: Returning user data for ID %d", user.ID)
|
||||
utils.WriteJSON(w, http.StatusOK, user)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -146,41 +132,42 @@ func UpdateUserHandler(store db.DB) http.HandlerFunc {
|
|||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
l.Debug().Msg("UpdateUserHandler: Received request to update user information")
|
||||
u := middleware.GetUserFromContext(ctx)
|
||||
if u == nil {
|
||||
l.Debug().Msg("UpdateUserHandler: Unauthorized request (user context is nil)")
|
||||
l.Debug().Msg("UpdateUserHandler: Received request")
|
||||
|
||||
user := middleware.GetUserFromContext(ctx)
|
||||
if user == nil {
|
||||
l.Debug().Msg("UpdateUserHandler: Unauthorized access")
|
||||
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
l.Err(err).Msg("UpdateUserHandler: Failed to parse request form")
|
||||
utils.WriteError(w, "failed to parse request", http.StatusInternalServerError)
|
||||
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)
|
||||
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 := r.ParseForm(); err != nil {
|
||||
l.Error().Err(err).Msg("UpdateUserHandler: Invalid form data")
|
||||
utils.WriteError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug().Msgf("UpdateUserHandler: Successfully updated user with ID %d", u.ID)
|
||||
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
|
||||
}
|
||||
|
||||
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: User %d updated", user.ID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
"github.com/gabehf/koito/internal/utils"
|
||||
)
|
||||
|
||||
// DeleteTrackHandler deletes a track by its ID.
|
||||
func DeleteTrackHandler(store db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
|
@ -46,7 +45,6 @@ func DeleteTrackHandler(store db.DB) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// DeleteListenHandler deletes a listen record by track ID and timestamp.
|
||||
func DeleteListenHandler(store db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
|
@ -96,7 +94,6 @@ func DeleteListenHandler(store db.DB) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// DeleteArtistHandler deletes an artist by its ID.
|
||||
func DeleteArtistHandler(store db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
|
@ -132,7 +129,6 @@ func DeleteArtistHandler(store db.DB) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// DeleteAlbumHandler deletes an album by its ID.
|
||||
func DeleteAlbumHandler(store db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
|
|
|||
|
|
@ -117,7 +117,12 @@ func serveDefaultImage(w http.ResponseWriter, r *http.Request, size catalog.Imag
|
|||
return
|
||||
}
|
||||
lock.Lock()
|
||||
utils.CopyFile(path.Join("assets", "default_img"), defaultImagePath)
|
||||
err = utils.CopyFile(path.Join("assets", "default_img"), defaultImagePath)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("serveDefaultImage: Error when copying default image from assets")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
lock.Unlock()
|
||||
} else if err != nil {
|
||||
l.Err(err).Msg("serveDefaultImage: Error when attempting to read default image in cache")
|
||||
|
|
@ -151,7 +156,7 @@ func serveDefaultImage(w http.ResponseWriter, r *http.Request, size catalog.Imag
|
|||
func downloadMissingImage(ctx context.Context, store db.DB, id uuid.UUID) (string, error) {
|
||||
src, err := store.GetImageSource(ctx, id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("downloadMissingImage: store.GetImageSource: %w", err)
|
||||
return "", fmt.Errorf("downloadMissingImage: %w", err)
|
||||
}
|
||||
var size catalog.ImageSize
|
||||
if cfg.FullImageCacheEnabled() {
|
||||
|
|
@ -161,7 +166,7 @@ func downloadMissingImage(ctx context.Context, store db.DB, id uuid.UUID) (strin
|
|||
}
|
||||
err = catalog.DownloadAndCacheImage(ctx, id, src, size)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("downloadMissingImage: catalog.DownloadAndCacheImage: %w", err)
|
||||
return "", fmt.Errorf("downloadMissingImage: %w", err)
|
||||
}
|
||||
return path.Join(catalog.SourceImageDir(), id.String()), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,13 +137,13 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
|
|||
|
||||
artistMbzIDs, err := utils.ParseUUIDSlice(payload.TrackMeta.AdditionalInfo.ArtistMBIDs)
|
||||
if err != nil {
|
||||
l.Debug().Err(err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs")
|
||||
l.Debug().AnErr("error", err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs")
|
||||
}
|
||||
if len(artistMbzIDs) < 1 {
|
||||
l.Debug().Err(err).Msg("LbzSubmitListenHandler: Attempting to parse artist UUIDs from mbid_mapping")
|
||||
l.Debug().AnErr("error", err).Msg("LbzSubmitListenHandler: Attempting to parse artist UUIDs from mbid_mapping")
|
||||
utils.ParseUUIDSlice(payload.TrackMeta.MBIDMapping.ArtistMBIDs)
|
||||
if err != nil {
|
||||
l.Debug().Err(err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs")
|
||||
l.Debug().AnErr("error", err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs")
|
||||
}
|
||||
}
|
||||
rgMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseGroupMBID)
|
||||
|
|
@ -191,7 +191,7 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
|
|||
}
|
||||
mbid, err := uuid.Parse(a.ArtistMBID)
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("LbzSubmitListenHandler: Failed to parse UUID for artist '%s'", a.ArtistName)
|
||||
l.Debug().AnErr("error", err).Msgf("LbzSubmitListenHandler: Failed to parse UUID for artist '%s'", a.ArtistName)
|
||||
}
|
||||
artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: a.ArtistName, Mbid: mbid})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ func makeAuthRequest(t *testing.T, session, method, endpoint string, body io.Rea
|
|||
Name: "koito_session",
|
||||
Value: session,
|
||||
})
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
t.Logf("Making request to %s with session: %s", endpoint, session)
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
|
@ -512,7 +513,7 @@ func TestAuth(t *testing.T) {
|
|||
encoded = formdata.Encode()
|
||||
resp, err = http.DefaultClient.Post(host()+"/apis/web/v1/login", "application/x-www-form-urlencoded", strings.NewReader(encoded))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 400, resp.StatusCode)
|
||||
require.Equal(t, 401, resp.StatusCode)
|
||||
|
||||
// reset update so other tests dont fail
|
||||
req, err = http.NewRequest("PATCH", host()+fmt.Sprintf("/apis/web/v1/user?username=%s&password=%s", cfg.DefaultUsername(), cfg.DefaultPassword()), nil)
|
||||
|
|
@ -732,3 +733,160 @@ func TestAlbumReplaceImage(t *testing.T) {
|
|||
assert.NotNil(t, a.Image)
|
||||
assert.Equal(t, newid, *a.Image)
|
||||
}
|
||||
|
||||
func TestSetPrimaryArtist(t *testing.T) {
|
||||
|
||||
t.Run("Submit Listens", doSubmitListens)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// set and unset track primary artist
|
||||
|
||||
formdata := url.Values{}
|
||||
formdata.Set("artist_id", "1")
|
||||
formdata.Set("track_id", "1")
|
||||
formdata.Set("is_primary", "false")
|
||||
body := formdata.Encode()
|
||||
resp, err := makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 204, resp.StatusCode)
|
||||
|
||||
exists, err := store.RowExists(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM artist_tracks
|
||||
WHERE track_id = $1 AND artist_id = $2 AND is_primary = $3
|
||||
)`, 1, 1, false)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "expected artist is_primary to be false")
|
||||
|
||||
formdata = url.Values{}
|
||||
formdata.Set("artist_id", "1")
|
||||
formdata.Set("track_id", "1")
|
||||
formdata.Set("is_primary", "true")
|
||||
body = formdata.Encode()
|
||||
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 204, resp.StatusCode)
|
||||
|
||||
exists, err = store.RowExists(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM artist_tracks
|
||||
WHERE track_id = $1 AND artist_id = $2 AND is_primary = $3
|
||||
)`, 1, 1, true)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "expected artist is_primary to be true")
|
||||
|
||||
// set and unset album primary artist
|
||||
|
||||
formdata = url.Values{}
|
||||
formdata.Set("artist_id", "1")
|
||||
formdata.Set("album_id", "1")
|
||||
formdata.Set("is_primary", "false")
|
||||
body = formdata.Encode()
|
||||
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 204, resp.StatusCode)
|
||||
|
||||
exists, err = store.RowExists(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM artist_releases
|
||||
WHERE release_id = $1 AND artist_id = $2 AND is_primary = $3
|
||||
)`, 1, 1, false)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "expected artist is_primary to be false")
|
||||
|
||||
formdata = url.Values{}
|
||||
formdata.Set("artist_id", "1")
|
||||
formdata.Set("album_id", "1")
|
||||
formdata.Set("is_primary", "true")
|
||||
body = formdata.Encode()
|
||||
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 204, resp.StatusCode)
|
||||
|
||||
exists, err = store.RowExists(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM artist_releases
|
||||
WHERE release_id = $1 AND artist_id = $2 AND is_primary = $3
|
||||
)`, 1, 1, true)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "expected artist is_primary to be true")
|
||||
|
||||
// create a new track with multiple artists to make sure only one is primary at a time
|
||||
|
||||
listenBody := `{
|
||||
"listen_type": "single",
|
||||
"payload": [
|
||||
{
|
||||
"listened_at": 1749475719,
|
||||
"track_metadata": {
|
||||
"additional_info": {
|
||||
"artist_names": [
|
||||
"Rat Tally",
|
||||
"Madeline Kenney"
|
||||
],
|
||||
"duration_ms": 197270,
|
||||
"submission_client": "navidrome",
|
||||
"submission_client_version": "0.56.1 (fa2cf362)"
|
||||
},
|
||||
"artist_name": "Rat Tally feat. Madeline Kenney",
|
||||
"release_name": "In My Car",
|
||||
"track_name": "In My Car"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
req, err := http.NewRequest("POST", host()+"/apis/listenbrainz/1/submit-listens", strings.NewReader(listenBody))
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Token %s", apikey))
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
respBytes, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `{"status": "ok"}`, string(respBytes))
|
||||
|
||||
// set both artists as primary
|
||||
|
||||
formdata = url.Values{}
|
||||
formdata.Set("artist_id", "4")
|
||||
formdata.Set("album_id", "4")
|
||||
formdata.Set("is_primary", "true")
|
||||
body = formdata.Encode()
|
||||
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 204, resp.StatusCode)
|
||||
formdata = url.Values{}
|
||||
formdata.Set("artist_id", "5")
|
||||
formdata.Set("album_id", "4")
|
||||
formdata.Set("is_primary", "true")
|
||||
body = formdata.Encode()
|
||||
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 204, resp.StatusCode)
|
||||
|
||||
formdata = url.Values{}
|
||||
formdata.Set("artist_id", "4")
|
||||
formdata.Set("track_id", "4")
|
||||
formdata.Set("is_primary", "true")
|
||||
body = formdata.Encode()
|
||||
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 204, resp.StatusCode)
|
||||
formdata = url.Values{}
|
||||
formdata.Set("artist_id", "5")
|
||||
formdata.Set("track_id", "4")
|
||||
formdata.Set("is_primary", "true")
|
||||
body = formdata.Encode()
|
||||
resp, err = makeAuthRequest(t, session, "POST", "/apis/web/v1/artists/primary", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 204, resp.StatusCode)
|
||||
|
||||
count, err := store.Count(ctx, `SELECT COUNT(*) FROM artist_releases WHERE release_id = $1 AND is_primary = $2`, 4, true)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, count, "expected only one primary artist for release")
|
||||
count, err = store.Count(ctx, `SELECT COUNT(*) FROM artist_tracks WHERE track_id = $1 AND is_primary = $2`, 4, true)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, count, "expected only one primary artist for track")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ func bindRoutes(
|
|||
|
||||
r.Route("/apis/web/v1", func(r chi.Router) {
|
||||
r.Get("/artist", handlers.GetArtistHandler(db))
|
||||
r.Get("/artists", handlers.GetArtistsForItemHandler(db))
|
||||
r.Get("/album", handlers.GetAlbumHandler(db))
|
||||
r.Get("/track", handlers.GetTrackHandler(db))
|
||||
r.Get("/top-tracks", handlers.GetTopTracksHandler(db))
|
||||
|
|
@ -75,6 +76,7 @@ func bindRoutes(
|
|||
r.Post("/merge/albums", handlers.MergeReleaseGroupsHandler(db))
|
||||
r.Post("/merge/artists", handlers.MergeArtistsHandler(db))
|
||||
r.Delete("/artist", handlers.DeleteArtistHandler(db))
|
||||
r.Post("/artists/primary", handlers.SetPrimaryArtistHandler(db))
|
||||
r.Delete("/album", handlers.DeleteAlbumHandler(db))
|
||||
r.Delete("/track", handlers.DeleteTrackHandler(db))
|
||||
r.Delete("/listen", handlers.DeleteListenHandler(db))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package catalog
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/gabehf/koito/internal/cfg"
|
||||
|
|
@ -23,12 +24,13 @@ type AssociateAlbumOpts struct {
|
|||
ReleaseName string
|
||||
TrackName string // required
|
||||
Mbzc mbz.MusicBrainzCaller
|
||||
SkipCacheImage bool
|
||||
}
|
||||
|
||||
func AssociateAlbum(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) {
|
||||
l := logger.FromContext(ctx)
|
||||
if opts.TrackName == "" {
|
||||
return nil, errors.New("required parameter TrackName missing")
|
||||
return nil, errors.New("AssociateAlbum: required parameter TrackName missing")
|
||||
}
|
||||
releaseTitle := opts.ReleaseName
|
||||
if releaseTitle == "" {
|
||||
|
|
@ -56,7 +58,7 @@ func matchAlbumByMbzReleaseID(ctx context.Context, d db.DB, opts AssociateAlbumO
|
|||
Image: a.Image,
|
||||
}, nil
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("matchAlbumByMbzReleaseID: %w", err)
|
||||
} else {
|
||||
l.Debug().Msgf("Album '%s' could not be found by MusicBrainz Release ID", opts.ReleaseName)
|
||||
rg, err := createOrUpdateAlbumWithMbzReleaseID(ctx, d, opts)
|
||||
|
|
@ -69,14 +71,17 @@ func matchAlbumByMbzReleaseID(ctx context.Context, d db.DB, opts AssociateAlbumO
|
|||
|
||||
func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) {
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
release, err := opts.Mbzc.GetRelease(ctx, opts.ReleaseMbzID)
|
||||
if err != nil {
|
||||
l.Warn().Msg("MusicBrainz unreachable, falling back to release title matching")
|
||||
l.Warn().Msg("createOrUpdateAlbumWithMbzReleaseID: MusicBrainz unreachable, falling back to release title matching")
|
||||
return matchAlbumByTitle(ctx, d, opts)
|
||||
}
|
||||
|
||||
var album *models.Album
|
||||
titles := []string{release.Title, opts.ReleaseName}
|
||||
utils.Unique(&titles)
|
||||
|
||||
l.Debug().Msgf("Searching for albums '%v' from artist id %d in DB", titles, opts.Artists[0].ID)
|
||||
album, err = d.GetAlbum(ctx, db.GetAlbumOpts{
|
||||
ArtistID: opts.Artists[0].ID,
|
||||
|
|
@ -89,27 +94,29 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso
|
|||
MusicBrainzID: opts.ReleaseMbzID,
|
||||
})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to update album with MusicBrainz Release ID")
|
||||
return nil, err
|
||||
l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to update album with MusicBrainz Release ID")
|
||||
return nil, fmt.Errorf("createOrUpdateAlbumWithMbzReleaseID: %w", err)
|
||||
}
|
||||
l.Debug().Msgf("Updated album '%s' with MusicBrainz Release ID", album.Title)
|
||||
|
||||
if opts.ReleaseGroupMbzID != uuid.Nil {
|
||||
aliases, err := opts.Mbzc.GetReleaseTitles(ctx, opts.ReleaseGroupMbzID)
|
||||
if err == nil {
|
||||
l.Debug().Msgf("Associating aliases '%s' with Release '%s'", aliases, album.Title)
|
||||
err = d.SaveAlbumAliases(ctx, album.ID, aliases, "MusicBrainz")
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to save aliases")
|
||||
l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to save aliases")
|
||||
}
|
||||
} else {
|
||||
l.Info().AnErr("err", err).Msg("Failed to get release group from MusicBrainz")
|
||||
l.Info().AnErr("err", err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to get release group from MusicBrainz")
|
||||
}
|
||||
}
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
l.Err(err).Msg("Error while searching for album by MusicBrainz Release ID")
|
||||
return nil, err
|
||||
l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: error while searching for album by MusicBrainz Release ID")
|
||||
return nil, fmt.Errorf("createOrUpdateAlbumWithMbzReleaseID: %w", err)
|
||||
} else {
|
||||
l.Debug().Msgf("Album %s could not be found. Creating...", release.Title)
|
||||
|
||||
var variousArtists bool
|
||||
for _, artistCredit := range release.ArtistCredit {
|
||||
if artistCredit.Name == "Various Artists" {
|
||||
|
|
@ -117,6 +124,7 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso
|
|||
variousArtists = true
|
||||
}
|
||||
}
|
||||
|
||||
l.Debug().Msg("Searching for album images...")
|
||||
var imgid uuid.UUID
|
||||
imgUrl, err := images.GetAlbumImage(ctx, images.AlbumImageOpts{
|
||||
|
|
@ -124,23 +132,28 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso
|
|||
Album: release.Title,
|
||||
ReleaseMbzID: &opts.ReleaseMbzID,
|
||||
})
|
||||
|
||||
if err == nil && imgUrl != "" {
|
||||
var size ImageSize
|
||||
if cfg.FullImageCacheEnabled() {
|
||||
size = ImageSizeFull
|
||||
} else {
|
||||
size = ImageSizeLarge
|
||||
}
|
||||
imgid = uuid.New()
|
||||
l.Debug().Msg("Downloading album image from source...")
|
||||
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to cache image")
|
||||
if !opts.SkipCacheImage {
|
||||
var size ImageSize
|
||||
if cfg.FullImageCacheEnabled() {
|
||||
size = ImageSizeFull
|
||||
} else {
|
||||
size = ImageSizeLarge
|
||||
}
|
||||
l.Debug().Msg("Downloading album image from source...")
|
||||
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to cache image")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
l.Debug().Msgf("Failed to get album images for %s: %s", release.Title, err.Error())
|
||||
l.Debug().Msgf("createOrUpdateAlbumWithMbzReleaseID: failed to get album images for %s: %s", release.Title, err.Error())
|
||||
}
|
||||
|
||||
album, err = d.SaveAlbum(ctx, db.SaveAlbumOpts{
|
||||
Title: release.Title,
|
||||
MusicBrainzID: opts.ReleaseMbzID,
|
||||
|
|
@ -150,22 +163,25 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso
|
|||
ImageSrc: imgUrl,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("createOrUpdateAlbumWithMbzReleaseID: %w", err)
|
||||
}
|
||||
|
||||
if opts.ReleaseGroupMbzID != uuid.Nil {
|
||||
aliases, err := opts.Mbzc.GetReleaseTitles(ctx, opts.ReleaseGroupMbzID)
|
||||
if err == nil {
|
||||
l.Debug().Msgf("Associating aliases '%s' with Release '%s'", aliases, album.Title)
|
||||
err = d.SaveAlbumAliases(ctx, album.ID, aliases, "MusicBrainz")
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to save aliases")
|
||||
l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to save aliases")
|
||||
}
|
||||
} else {
|
||||
l.Info().AnErr("err", err).Msg("Failed to get release group from MusicBrainz")
|
||||
l.Info().AnErr("err", err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to get release group from MusicBrainz")
|
||||
}
|
||||
}
|
||||
|
||||
l.Info().Msgf("Created album '%s' with MusicBrainz Release ID", album.Title)
|
||||
}
|
||||
|
||||
return &models.Album{
|
||||
ID: album.ID,
|
||||
MbzID: &opts.ReleaseMbzID,
|
||||
|
|
@ -176,12 +192,14 @@ func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts Asso
|
|||
|
||||
func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) {
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
var releaseName string
|
||||
if opts.ReleaseName != "" {
|
||||
releaseName = opts.ReleaseName
|
||||
} else {
|
||||
releaseName = opts.TrackName
|
||||
}
|
||||
|
||||
a, err := d.GetAlbum(ctx, db.GetAlbumOpts{
|
||||
Title: releaseName,
|
||||
ArtistID: opts.Artists[0].ID,
|
||||
|
|
@ -195,11 +213,11 @@ func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*
|
|||
MusicBrainzID: opts.ReleaseMbzID,
|
||||
})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to associate existing release with MusicBrainz ID")
|
||||
l.Err(err).Msg("matchAlbumByTitle: failed to associate existing release with MusicBrainz ID")
|
||||
}
|
||||
}
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("matchAlbumByTitle: %w", err)
|
||||
} else {
|
||||
var imgid uuid.UUID
|
||||
imgUrl, err := images.GetAlbumImage(ctx, images.AlbumImageOpts{
|
||||
|
|
@ -208,22 +226,25 @@ func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*
|
|||
ReleaseMbzID: &opts.ReleaseMbzID,
|
||||
})
|
||||
if err == nil && imgUrl != "" {
|
||||
var size ImageSize
|
||||
if cfg.FullImageCacheEnabled() {
|
||||
size = ImageSizeFull
|
||||
} else {
|
||||
size = ImageSizeLarge
|
||||
}
|
||||
imgid = uuid.New()
|
||||
l.Debug().Msg("Downloading album image from source...")
|
||||
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to cache image")
|
||||
if !opts.SkipCacheImage {
|
||||
var size ImageSize
|
||||
if cfg.FullImageCacheEnabled() {
|
||||
size = ImageSizeFull
|
||||
} else {
|
||||
size = ImageSizeLarge
|
||||
}
|
||||
l.Debug().Msg("Downloading album image from source...")
|
||||
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("createOrUpdateAlbumWithMbzReleaseID: failed to cache image")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
l.Debug().Msgf("Failed to get album images for %s: %s", opts.ReleaseName, err.Error())
|
||||
l.Debug().AnErr("error", err).Msgf("matchAlbumByTitle: failed to get album images for %s", opts.ReleaseName)
|
||||
}
|
||||
|
||||
a, err = d.SaveAlbum(ctx, db.SaveAlbumOpts{
|
||||
Title: releaseName,
|
||||
ArtistIDs: utils.FlattenArtistIDs(opts.Artists),
|
||||
|
|
@ -232,10 +253,11 @@ func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*
|
|||
ImageSrc: imgUrl,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("matchAlbumByTitle: %w", err)
|
||||
}
|
||||
l.Info().Msgf("Created album '%s' with artist and title", a.Title)
|
||||
}
|
||||
|
||||
return &models.Album{
|
||||
ID: a.ID,
|
||||
Title: a.Title,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ type AssociateArtistsOpts struct {
|
|||
ArtistName string
|
||||
TrackTitle string
|
||||
Mbzc mbz.MusicBrainzCaller
|
||||
|
||||
SkipCacheImage bool
|
||||
}
|
||||
|
||||
func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) {
|
||||
|
|
@ -36,7 +38,7 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
|
|||
l.Debug().Msg("Associating artists by MusicBrainz ID(s) mappings")
|
||||
mbzMatches, err := matchArtistsByMBIDMappings(ctx, d, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("AssociateArtists: %w", err)
|
||||
}
|
||||
result = append(result, mbzMatches...)
|
||||
}
|
||||
|
|
@ -45,16 +47,16 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
|
|||
l.Debug().Msg("Associating artists by list of MusicBrainz ID(s)")
|
||||
mbzMatches, err := matchArtistsByMBID(ctx, d, opts, result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("AssociateArtists: %w", err)
|
||||
}
|
||||
result = append(result, mbzMatches...)
|
||||
}
|
||||
|
||||
if len(opts.ArtistNames) > len(result) {
|
||||
l.Debug().Msg("Associating artists by list of artist names")
|
||||
nameMatches, err := matchArtistsByNames(ctx, opts.ArtistNames, result, d)
|
||||
nameMatches, err := matchArtistsByNames(ctx, opts.ArtistNames, result, d, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("AssociateArtists: %w", err)
|
||||
}
|
||||
result = append(result, nameMatches...)
|
||||
}
|
||||
|
|
@ -62,9 +64,9 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
|
|||
if len(result) < 1 {
|
||||
allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
|
||||
l.Debug().Msgf("Associating artists by artist name(s) %v and track title '%s'", allArtists, opts.TrackTitle)
|
||||
fallbackMatches, err := matchArtistsByNames(ctx, allArtists, nil, d)
|
||||
fallbackMatches, err := matchArtistsByNames(ctx, allArtists, nil, d, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("AssociateArtists: %w", err)
|
||||
}
|
||||
result = append(result, fallbackMatches...)
|
||||
}
|
||||
|
|
@ -77,7 +79,6 @@ func matchArtistsByMBIDMappings(ctx context.Context, d db.DB, opts AssociateArti
|
|||
var result []*models.Artist
|
||||
|
||||
for _, a := range opts.ArtistMbidMap {
|
||||
// first, try to get by mbid
|
||||
artist, err := d.GetArtist(ctx, db.GetArtistOpts{
|
||||
MusicBrainzID: a.Mbid,
|
||||
})
|
||||
|
|
@ -87,18 +88,17 @@ func matchArtistsByMBIDMappings(ctx context.Context, d db.DB, opts AssociateArti
|
|||
continue
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("matchArtistsBYMBIDMappings: %w", err)
|
||||
return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err)
|
||||
}
|
||||
// then, try to get by mbz name
|
||||
|
||||
artist, err = d.GetArtist(ctx, db.GetArtistOpts{
|
||||
Name: a.Artist,
|
||||
})
|
||||
if err == nil {
|
||||
l.Debug().Msgf("Artist '%s' found by Name", a.Artist)
|
||||
// ...associate with mbzid if found
|
||||
err = d.UpdateArtist(ctx, db.UpdateArtistOpts{ID: artist.ID, MusicBrainzID: a.Mbid})
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("matchArtistsBYMBIDMappings: %w", err)).Msgf("Failed to associate artist '%s' with MusicBrainz ID", artist.Name)
|
||||
l.Err(err).Msgf("matchArtistsByMBIDMappings: Failed to associate artist '%s' with MusicBrainz ID", artist.Name)
|
||||
} else {
|
||||
artist.MbzID = &a.Mbid
|
||||
}
|
||||
|
|
@ -106,36 +106,51 @@ func matchArtistsByMBIDMappings(ctx context.Context, d db.DB, opts AssociateArti
|
|||
continue
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("matchArtistsBYMBIDMappings: %w", err)
|
||||
return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err)
|
||||
}
|
||||
|
||||
// then, try to get by aliases, or create
|
||||
artist, err = resolveAliasOrCreateArtist(ctx, a.Mbid, opts.ArtistNames, d, opts.Mbzc)
|
||||
artist, err = resolveAliasOrCreateArtist(ctx, a.Mbid, opts.ArtistNames, d, opts)
|
||||
if err != nil {
|
||||
// if mbz unreachable, just create a new artist with provided name and mbid
|
||||
l.Warn().Msg("MusicBrainz unreachable, creating new artist with provided MusicBrainz ID mapping")
|
||||
l.Warn().AnErr("error", err).Msg("matchArtistsByMBIDMappings: MusicBrainz unreachable, creating new artist with provided MusicBrainz ID mapping")
|
||||
|
||||
var imgid uuid.UUID
|
||||
imgUrl, err := images.GetArtistImage(ctx, images.ArtistImageOpts{
|
||||
imgUrl, imgErr := images.GetArtistImage(ctx, images.ArtistImageOpts{
|
||||
Aliases: []string{a.Artist},
|
||||
})
|
||||
if err == nil {
|
||||
if imgErr == nil && imgUrl != "" {
|
||||
imgid = uuid.New()
|
||||
err = DownloadAndCacheImage(ctx, imgid, imgUrl, ImageSourceSize())
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to download artist image for artist '%s'", a.Artist)
|
||||
imgid = uuid.Nil
|
||||
if !opts.SkipCacheImage {
|
||||
var size ImageSize
|
||||
if cfg.FullImageCacheEnabled() {
|
||||
size = ImageSizeFull
|
||||
} else {
|
||||
size = ImageSizeLarge
|
||||
}
|
||||
l.Debug().Msg("Downloading artist image from source...")
|
||||
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to cache image")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to get artist image for artist '%s'", a.Artist)
|
||||
l.Err(imgErr).Msgf("matchArtistsByMBIDMappings: Failed to get artist image for artist '%s'", a.Artist)
|
||||
}
|
||||
artist, err = d.SaveArtist(ctx, db.SaveArtistOpts{Name: a.Artist, MusicBrainzID: a.Mbid, Image: imgid, ImageSrc: imgUrl})
|
||||
|
||||
artist, err = d.SaveArtist(ctx, db.SaveArtistOpts{
|
||||
Name: a.Artist,
|
||||
MusicBrainzID: a.Mbid,
|
||||
Image: imgid,
|
||||
ImageSrc: imgUrl,
|
||||
})
|
||||
if err != nil {
|
||||
l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to create artist '%s' in database", a.Artist)
|
||||
l.Err(err).Msgf("matchArtistsByMBIDMappings: Failed to create artist '%s' in database", a.Artist)
|
||||
return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, artist)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +165,7 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts,
|
|||
}
|
||||
if id == uuid.Nil {
|
||||
l.Warn().Msg("Provided artist has uuid.Nil MusicBrainzID")
|
||||
return matchArtistsByNames(ctx, opts.ArtistNames, result, d)
|
||||
return matchArtistsByNames(ctx, opts.ArtistNames, result, d, opts)
|
||||
}
|
||||
a, err := d.GetArtist(ctx, db.GetArtistOpts{
|
||||
MusicBrainzID: id,
|
||||
|
|
@ -160,7 +175,6 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts,
|
|||
result = append(result, a)
|
||||
continue
|
||||
}
|
||||
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -168,22 +182,25 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts,
|
|||
if len(opts.ArtistNames) < 1 {
|
||||
opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
|
||||
}
|
||||
a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts.Mbzc)
|
||||
|
||||
a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts)
|
||||
if err != nil {
|
||||
l.Warn().Msg("MusicBrainz unreachable, falling back to artist name matching")
|
||||
return matchArtistsByNames(ctx, opts.ArtistNames, result, d)
|
||||
// return nil, err
|
||||
return matchArtistsByNames(ctx, opts.ArtistNames, result, d, opts)
|
||||
}
|
||||
|
||||
result = append(result, a)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []string, d db.DB, mbz mbz.MusicBrainzCaller) (*models.Artist, error) {
|
||||
|
||||
func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []string, d db.DB, opts AssociateArtistsOpts) (*models.Artist, error) {
|
||||
l := logger.FromContext(ctx)
|
||||
|
||||
aliases, err := mbz.GetArtistPrimaryAliases(ctx, mbzID)
|
||||
aliases, err := opts.Mbzc.GetArtistPrimaryAliases(ctx, mbzID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("resolveAliasOrCreateArtist: %w", err)
|
||||
}
|
||||
l.Debug().Msgf("Got aliases %v from MusicBrainz", aliases)
|
||||
|
||||
|
|
@ -195,10 +212,10 @@ func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []st
|
|||
a.MbzID = &mbzID
|
||||
l.Debug().Msgf("Alias '%s' found in DB. Associating with MusicBrainz ID...", alias)
|
||||
if updateErr := d.UpdateArtist(ctx, db.UpdateArtistOpts{ID: a.ID, MusicBrainzID: mbzID}); updateErr != nil {
|
||||
return nil, updateErr
|
||||
return nil, fmt.Errorf("resolveAliasOrCreateArtist: %w", updateErr)
|
||||
}
|
||||
if saveAliasErr := d.SaveArtistAliases(ctx, a.ID, aliases, "MusicBrainz"); saveAliasErr != nil {
|
||||
return nil, saveAliasErr
|
||||
return nil, fmt.Errorf("resolveAliasOrCreateArtist: %w", saveAliasErr)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
|
@ -220,20 +237,22 @@ func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []st
|
|||
Aliases: aliases,
|
||||
})
|
||||
if err == nil && imgUrl != "" {
|
||||
var size ImageSize
|
||||
if cfg.FullImageCacheEnabled() {
|
||||
size = ImageSizeFull
|
||||
} else {
|
||||
size = ImageSizeLarge
|
||||
}
|
||||
imgid = uuid.New()
|
||||
l.Debug().Msg("Downloading artist image from source...")
|
||||
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to cache image")
|
||||
if !opts.SkipCacheImage {
|
||||
var size ImageSize
|
||||
if cfg.FullImageCacheEnabled() {
|
||||
size = ImageSizeFull
|
||||
} else {
|
||||
size = ImageSizeLarge
|
||||
}
|
||||
l.Debug().Msg("Downloading artist image from source...")
|
||||
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to cache image")
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
l.Warn().Msgf("Failed to get artist image from ImageSrc: %s", err.Error())
|
||||
l.Warn().AnErr("error", err).Msg("Failed to get artist image from ImageSrc")
|
||||
}
|
||||
|
||||
u, err := d.SaveArtist(ctx, db.SaveArtistOpts{
|
||||
|
|
@ -244,13 +263,13 @@ func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []st
|
|||
ImageSrc: imgUrl,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("resolveAliasOrCreateArtist: %w", err)
|
||||
}
|
||||
l.Info().Msgf("Created artist '%s' with MusicBrainz Artist ID", canonical)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func matchArtistsByNames(ctx context.Context, names []string, existing []*models.Artist, d db.DB) ([]*models.Artist, error) {
|
||||
func matchArtistsByNames(ctx context.Context, names []string, existing []*models.Artist, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) {
|
||||
l := logger.FromContext(ctx)
|
||||
var result []*models.Artist
|
||||
|
||||
|
|
@ -273,29 +292,31 @@ func matchArtistsByNames(ctx context.Context, names []string, existing []*models
|
|||
Aliases: []string{name},
|
||||
})
|
||||
if err == nil && imgUrl != "" {
|
||||
var size ImageSize
|
||||
if cfg.FullImageCacheEnabled() {
|
||||
size = ImageSizeFull
|
||||
} else {
|
||||
size = ImageSizeLarge
|
||||
}
|
||||
imgid = uuid.New()
|
||||
l.Debug().Msg("Downloading artist image from source...")
|
||||
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to cache image")
|
||||
if !opts.SkipCacheImage {
|
||||
var size ImageSize
|
||||
if cfg.FullImageCacheEnabled() {
|
||||
size = ImageSizeFull
|
||||
} else {
|
||||
size = ImageSizeLarge
|
||||
}
|
||||
l.Debug().Msg("Downloading artist image from source...")
|
||||
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to cache image")
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
l.Debug().Msgf("Failed to get artist images for %s: %s", name, err.Error())
|
||||
l.Debug().AnErr("error", err).Msgf("Failed to get artist images for %s", name)
|
||||
}
|
||||
a, err = d.SaveArtist(ctx, db.SaveArtistOpts{Name: name, Image: imgid, ImageSrc: imgUrl})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("matchArtistsByNames: %w", err)
|
||||
}
|
||||
l.Info().Msgf("Created artist '%s' with artist name", name)
|
||||
result = append(result, a)
|
||||
} else {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("matchArtistsByNames: %w", err)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package catalog
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gabehf/koito/internal/db"
|
||||
"github.com/gabehf/koito/internal/logger"
|
||||
|
|
@ -24,13 +25,13 @@ type AssociateTrackOpts struct {
|
|||
func AssociateTrack(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*models.Track, error) {
|
||||
l := logger.FromContext(ctx)
|
||||
if opts.TrackName == "" {
|
||||
return nil, errors.New("missing required parameter 'opts.TrackName'")
|
||||
return nil, errors.New("AssociateTrack: missing required parameter 'opts.TrackName'")
|
||||
}
|
||||
if len(opts.ArtistIDs) < 1 {
|
||||
return nil, errors.New("at least one artist id must be specified")
|
||||
return nil, errors.New("AssociateTrack: at least one artist id must be specified")
|
||||
}
|
||||
if opts.AlbumID == 0 {
|
||||
return nil, errors.New("release group id must be specified")
|
||||
return nil, errors.New("AssociateTrack: release group id must be specified")
|
||||
}
|
||||
// first, try to match track Mbz ID
|
||||
if opts.TrackMbzID != uuid.Nil {
|
||||
|
|
@ -52,12 +53,12 @@ func matchTrackByMbzID(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*
|
|||
l.Debug().Msgf("Found track '%s' by MusicBrainz ID", track.Title)
|
||||
return track, nil
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("matchTrackByMbzID: %w", err)
|
||||
} else {
|
||||
l.Debug().Msgf("Track '%s' could not be found by MusicBrainz ID", opts.TrackName)
|
||||
track, err := matchTrackByTitleAndArtist(ctx, d, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("matchTrackByMbzID: %w", err)
|
||||
}
|
||||
l.Debug().Msgf("Updating track '%s' with MusicBrainz ID %s", opts.TrackName, opts.TrackMbzID)
|
||||
err = d.UpdateTrack(ctx, db.UpdateTrackOpts{
|
||||
|
|
@ -65,7 +66,7 @@ func matchTrackByMbzID(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*
|
|||
MusicBrainzID: opts.TrackMbzID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("matchTrackByMbzID: %w", err)
|
||||
}
|
||||
track.MbzID = &opts.TrackMbzID
|
||||
return track, nil
|
||||
|
|
@ -83,7 +84,7 @@ func matchTrackByTitleAndArtist(ctx context.Context, d db.DB, opts AssociateTrac
|
|||
l.Debug().Msgf("Track '%s' found by title and artist match", track.Title)
|
||||
return track, nil
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("matchTrackByTitleAndArtist: %w", err)
|
||||
} else {
|
||||
if opts.TrackMbzID != uuid.Nil {
|
||||
mbzTrack, err := opts.Mbzc.GetTrack(ctx, opts.TrackMbzID)
|
||||
|
|
@ -107,7 +108,7 @@ func matchTrackByTitleAndArtist(ctx context.Context, d db.DB, opts AssociateTrac
|
|||
Duration: opts.Duration,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("matchTrackByTitleAndArtist: %w", err)
|
||||
}
|
||||
if opts.TrackMbzID == uuid.Nil {
|
||||
l.Info().Msgf("Created track '%s' with title and artist", opts.TrackName)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ package catalog
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -39,6 +40,9 @@ type SubmitListenOpts struct {
|
|||
// artist, release, release group, and track in DB
|
||||
SkipSaveListen bool
|
||||
|
||||
// When true, skips caching the images and only stores the image url in the db
|
||||
SkipCacheImage bool
|
||||
|
||||
MbzCaller mbz.MusicBrainzCaller
|
||||
ArtistNames []string
|
||||
Artist string
|
||||
|
|
@ -51,8 +55,9 @@ type SubmitListenOpts struct {
|
|||
ReleaseMbzID uuid.UUID
|
||||
ReleaseGroupMbzID uuid.UUID
|
||||
Time time.Time
|
||||
UserID int32
|
||||
Client string
|
||||
|
||||
UserID int32
|
||||
Client string
|
||||
}
|
||||
|
||||
const (
|
||||
|
|
@ -70,16 +75,17 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
|
|||
ctx,
|
||||
store,
|
||||
AssociateArtistsOpts{
|
||||
ArtistMbzIDs: opts.ArtistMbzIDs,
|
||||
ArtistNames: opts.ArtistNames,
|
||||
ArtistName: opts.Artist,
|
||||
ArtistMbidMap: opts.ArtistMbidMappings,
|
||||
Mbzc: opts.MbzCaller,
|
||||
TrackTitle: opts.TrackTitle,
|
||||
ArtistMbzIDs: opts.ArtistMbzIDs,
|
||||
ArtistNames: opts.ArtistNames,
|
||||
ArtistName: opts.Artist,
|
||||
ArtistMbidMap: opts.ArtistMbidMappings,
|
||||
Mbzc: opts.MbzCaller,
|
||||
TrackTitle: opts.TrackTitle,
|
||||
SkipCacheImage: opts.SkipCacheImage,
|
||||
})
|
||||
if err != nil {
|
||||
l.Error().Err(err).Msg("Failed to associate artists to listen")
|
||||
return err
|
||||
l.Err(err).Msg("Failed to associate artists to listen")
|
||||
return fmt.Errorf("SubmitListen: %w", err)
|
||||
} else if len(artists) < 1 {
|
||||
l.Debug().Msg("Failed to associate any artists to release")
|
||||
}
|
||||
|
|
@ -97,10 +103,11 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
|
|||
TrackName: opts.TrackTitle,
|
||||
Mbzc: opts.MbzCaller,
|
||||
Artists: artists,
|
||||
SkipCacheImage: opts.SkipCacheImage,
|
||||
})
|
||||
if err != nil {
|
||||
l.Error().Err(err).Msg("Failed to associate release group to listen")
|
||||
return err
|
||||
return fmt.Errorf("SubmitListen: %w", err)
|
||||
}
|
||||
l.Debug().Any("album", rg).Msg("Matched listen to release")
|
||||
|
||||
|
|
@ -120,7 +127,7 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
|
|||
})
|
||||
if err != nil {
|
||||
l.Error().Err(err).Msg("Failed to associate track to listen")
|
||||
return err
|
||||
return fmt.Errorf("SubmitListen: %w", err)
|
||||
}
|
||||
l.Debug().Any("track", track).Msg("Matched listen to track")
|
||||
|
||||
|
|
|
|||
|
|
@ -82,17 +82,17 @@ func SourceImageDir() string {
|
|||
func ValidateImageURL(url string) error {
|
||||
resp, err := http.Head(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to perform HEAD request: %w", err)
|
||||
return fmt.Errorf("ValidateImageURL: http.Head: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HEAD request failed, status code: %d", resp.StatusCode)
|
||||
return fmt.Errorf("ValidateImageURL: HEAD request failed, status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
return fmt.Errorf("URL does not point to an image, content type: %s", contentType)
|
||||
return fmt.Errorf("ValidateImageURL: URL does not point to an image, content type: %s", contentType)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -103,20 +103,24 @@ func DownloadAndCacheImage(ctx context.Context, id uuid.UUID, url string, size I
|
|||
l := logger.FromContext(ctx)
|
||||
err := ValidateImageURL(url)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("DownloadAndCacheImage: %w", err)
|
||||
}
|
||||
l.Debug().Msgf("Downloading image for ID %s", id)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download image: %w", err)
|
||||
return fmt.Errorf("DownloadAndCacheImage: http.Get: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to download image, status code: %d", resp.StatusCode)
|
||||
return fmt.Errorf("DownloadAndCacheImage: failed to download image, status: %s", resp.Status)
|
||||
}
|
||||
|
||||
return CompressAndSaveImage(ctx, id.String(), size, resp.Body)
|
||||
err = CompressAndSaveImage(ctx, id.String(), size, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DownloadAndCacheImage: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compresses an image to the specified size, then saves it to the correct cache folder.
|
||||
|
|
@ -124,16 +128,24 @@ func CompressAndSaveImage(ctx context.Context, filename string, size ImageSize,
|
|||
l := logger.FromContext(ctx)
|
||||
|
||||
if size == ImageSizeFull {
|
||||
return saveImage(filename, size, body)
|
||||
err := saveImage(filename, size, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CompressAndSaveImage: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
l.Debug().Msg("Creating resized image")
|
||||
compressed, err := compressImage(size, body)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("CompressAndSaveImage: %w", err)
|
||||
}
|
||||
|
||||
return saveImage(filename, size, compressed)
|
||||
err = saveImage(filename, size, compressed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CompressAndSaveImage: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveImage saves an image to the image_cache/{size} folder
|
||||
|
|
@ -144,21 +156,21 @@ func saveImage(filename string, size ImageSize, data io.Reader) error {
|
|||
// Ensure the cache directory exists
|
||||
err := os.MkdirAll(filepath.Join(cacheDir, string(size)), 0744)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create full image cache directory: %w", err)
|
||||
return fmt.Errorf("saveImage: failed to create full image cache directory: %w", err)
|
||||
}
|
||||
|
||||
// Create a file in the cache directory
|
||||
imagePath := filepath.Join(cacheDir, string(size), filename)
|
||||
file, err := os.Create(imagePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create image file: %w", err)
|
||||
return fmt.Errorf("saveImage: failed to create image file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Save the image to the file
|
||||
_, err = io.Copy(file, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save image: %w", err)
|
||||
return fmt.Errorf("saveImage: failed to save image: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -167,7 +179,7 @@ func saveImage(filename string, size ImageSize, data io.Reader) error {
|
|||
func compressImage(size ImageSize, data io.Reader) (io.Reader, error) {
|
||||
imgBytes, err := io.ReadAll(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("compressImage: io.ReadAll: %w", err)
|
||||
}
|
||||
px := GetImageSize(size)
|
||||
// Resize with bimg
|
||||
|
|
@ -180,10 +192,10 @@ func compressImage(size ImageSize, data io.Reader) (io.Reader, error) {
|
|||
Type: bimg.WEBP,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("compressImage: bimg.NewImage: %w", err)
|
||||
}
|
||||
if len(imgBytes) == 0 {
|
||||
return nil, fmt.Errorf("compression failed")
|
||||
return nil, fmt.Errorf("compressImage: failed to compress image: %w", err)
|
||||
}
|
||||
return bytes.NewReader(imgBytes), nil
|
||||
}
|
||||
|
|
@ -198,19 +210,19 @@ func DeleteImage(filename uuid.UUID) error {
|
|||
// }
|
||||
err := os.Remove(path.Join(cacheDir, "full", filename.String()))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
return fmt.Errorf("DeleteImage: %w", err)
|
||||
}
|
||||
err = os.Remove(path.Join(cacheDir, "large", filename.String()))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
return fmt.Errorf("DeleteImage: %w", err)
|
||||
}
|
||||
err = os.Remove(path.Join(cacheDir, "medium", filename.String()))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
return fmt.Errorf("DeleteImage: %w", err)
|
||||
}
|
||||
err = os.Remove(path.Join(cacheDir, "small", filename.String()))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
return fmt.Errorf("DeleteImage: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -230,7 +242,7 @@ func PruneOrphanedImages(ctx context.Context, store db.DB) error {
|
|||
for _, dir := range []string{"large", "medium", "small", "full"} {
|
||||
c, err := pruneDirImgs(ctx, store, path.Join(cacheDir, dir), memo)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("PruneOrphanedImages: %w", err)
|
||||
}
|
||||
count += c
|
||||
}
|
||||
|
|
@ -256,7 +268,7 @@ func pruneDirImgs(ctx context.Context, store db.DB, path string, memo map[string
|
|||
}
|
||||
exists, err := store.ImageHasAssociation(ctx, imageid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, fmt.Errorf("pruneDirImages: %w", err)
|
||||
} else if exists {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,30 +17,31 @@ const (
|
|||
|
||||
const (
|
||||
// BASE_URL_ENV = "KOITO_BASE_URL"
|
||||
DATABASE_URL_ENV = "KOITO_DATABASE_URL"
|
||||
BIND_ADDR_ENV = "KOITO_BIND_ADDR"
|
||||
LISTEN_PORT_ENV = "KOITO_LISTEN_PORT"
|
||||
ENABLE_STRUCTURED_LOGGING_ENV = "KOITO_ENABLE_STRUCTURED_LOGGING"
|
||||
ENABLE_FULL_IMAGE_CACHE_ENV = "KOITO_ENABLE_FULL_IMAGE_CACHE"
|
||||
LOG_LEVEL_ENV = "KOITO_LOG_LEVEL"
|
||||
MUSICBRAINZ_URL_ENV = "KOITO_MUSICBRAINZ_URL"
|
||||
MUSICBRAINZ_RATE_LIMIT_ENV = "KOITO_MUSICBRAINZ_RATE_LIMIT"
|
||||
ENABLE_LBZ_RELAY_ENV = "KOITO_ENABLE_LBZ_RELAY"
|
||||
LBZ_RELAY_URL_ENV = "KOITO_LBZ_RELAY_URL"
|
||||
LBZ_RELAY_TOKEN_ENV = "KOITO_LBZ_RELAY_TOKEN"
|
||||
CONFIG_DIR_ENV = "KOITO_CONFIG_DIR"
|
||||
DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME"
|
||||
DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD"
|
||||
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
|
||||
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
|
||||
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
|
||||
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
|
||||
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
|
||||
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
|
||||
DISABLE_RATE_LIMIT_ENV = "KOITO_DISABLE_RATE_LIMIT"
|
||||
THROTTLE_IMPORTS_MS = "KOITO_THROTTLE_IMPORTS_MS"
|
||||
IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX"
|
||||
IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX"
|
||||
DATABASE_URL_ENV = "KOITO_DATABASE_URL"
|
||||
BIND_ADDR_ENV = "KOITO_BIND_ADDR"
|
||||
LISTEN_PORT_ENV = "KOITO_LISTEN_PORT"
|
||||
ENABLE_STRUCTURED_LOGGING_ENV = "KOITO_ENABLE_STRUCTURED_LOGGING"
|
||||
ENABLE_FULL_IMAGE_CACHE_ENV = "KOITO_ENABLE_FULL_IMAGE_CACHE"
|
||||
LOG_LEVEL_ENV = "KOITO_LOG_LEVEL"
|
||||
MUSICBRAINZ_URL_ENV = "KOITO_MUSICBRAINZ_URL"
|
||||
MUSICBRAINZ_RATE_LIMIT_ENV = "KOITO_MUSICBRAINZ_RATE_LIMIT"
|
||||
ENABLE_LBZ_RELAY_ENV = "KOITO_ENABLE_LBZ_RELAY"
|
||||
LBZ_RELAY_URL_ENV = "KOITO_LBZ_RELAY_URL"
|
||||
LBZ_RELAY_TOKEN_ENV = "KOITO_LBZ_RELAY_TOKEN"
|
||||
CONFIG_DIR_ENV = "KOITO_CONFIG_DIR"
|
||||
DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME"
|
||||
DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD"
|
||||
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
|
||||
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
|
||||
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
|
||||
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
|
||||
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
|
||||
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
|
||||
DISABLE_RATE_LIMIT_ENV = "KOITO_DISABLE_RATE_LIMIT"
|
||||
THROTTLE_IMPORTS_MS = "KOITO_THROTTLE_IMPORTS_MS"
|
||||
IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX"
|
||||
IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX"
|
||||
FETCH_IMAGES_DURING_IMPORT_ENV = "KOITO_FETCH_IMAGES_DURING_IMPORT"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
|
|
@ -48,29 +49,30 @@ type config struct {
|
|||
listenPort int
|
||||
configDir string
|
||||
// baseUrl string
|
||||
databaseUrl string
|
||||
musicBrainzUrl string
|
||||
musicBrainzRateLimit int
|
||||
logLevel int
|
||||
structuredLogging bool
|
||||
enableFullImageCache bool
|
||||
lbzRelayEnabled bool
|
||||
lbzRelayUrl string
|
||||
lbzRelayToken string
|
||||
defaultPw string
|
||||
defaultUsername string
|
||||
disableDeezer bool
|
||||
disableCAA bool
|
||||
disableMusicBrainz bool
|
||||
skipImport bool
|
||||
allowedHosts []string
|
||||
allowAllHosts bool
|
||||
allowedOrigins []string
|
||||
disableRateLimit bool
|
||||
importThrottleMs int
|
||||
userAgent string
|
||||
importBefore time.Time
|
||||
importAfter time.Time
|
||||
databaseUrl string
|
||||
musicBrainzUrl string
|
||||
musicBrainzRateLimit int
|
||||
logLevel int
|
||||
structuredLogging bool
|
||||
enableFullImageCache bool
|
||||
lbzRelayEnabled bool
|
||||
lbzRelayUrl string
|
||||
lbzRelayToken string
|
||||
defaultPw string
|
||||
defaultUsername string
|
||||
disableDeezer bool
|
||||
disableCAA bool
|
||||
disableMusicBrainz bool
|
||||
skipImport bool
|
||||
fetchImageDuringImport bool
|
||||
allowedHosts []string
|
||||
allowAllHosts bool
|
||||
allowedOrigins []string
|
||||
disableRateLimit bool
|
||||
importThrottleMs int
|
||||
userAgent string
|
||||
importBefore time.Time
|
||||
importAfter time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -85,7 +87,10 @@ func Load(getenv func(string) string, version string) error {
|
|||
once.Do(func() {
|
||||
globalConfig, err = loadConfig(getenv, version)
|
||||
})
|
||||
return err
|
||||
if err != nil {
|
||||
return fmt.Errorf("cfg.Load: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadConfig loads the configuration from environment variables.
|
||||
|
|
@ -94,7 +99,7 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
|
|||
|
||||
cfg.databaseUrl = getenv(DATABASE_URL_ENV)
|
||||
if cfg.databaseUrl == "" {
|
||||
return nil, errors.New("required parameter " + DATABASE_URL_ENV + " not provided")
|
||||
return nil, errors.New("loadConfig: required parameter " + DATABASE_URL_ENV + " not provided")
|
||||
}
|
||||
cfg.bindAddr = getenv(BIND_ADDR_ENV)
|
||||
var err error
|
||||
|
|
@ -136,6 +141,7 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
|
|||
cfg.disableRateLimit = parseBool(getenv(DISABLE_RATE_LIMIT_ENV))
|
||||
|
||||
cfg.structuredLogging = parseBool(getenv(ENABLE_STRUCTURED_LOGGING_ENV))
|
||||
cfg.fetchImageDuringImport = parseBool(getenv(FETCH_IMAGES_DURING_IMPORT_ENV))
|
||||
|
||||
cfg.enableFullImageCache = parseBool(getenv(ENABLE_FULL_IMAGE_CACHE_ENV))
|
||||
cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV))
|
||||
|
|
@ -211,12 +217,6 @@ func ConfigDir() string {
|
|||
return globalConfig.configDir
|
||||
}
|
||||
|
||||
// func BaseUrl() string {
|
||||
// lock.RLock()
|
||||
// defer lock.RUnlock()
|
||||
// return globalConfig.baseUrl
|
||||
// }
|
||||
|
||||
func DatabaseUrl() string {
|
||||
lock.RLock()
|
||||
defer lock.RUnlock()
|
||||
|
|
@ -339,5 +339,13 @@ func ThrottleImportMs() int {
|
|||
|
||||
// returns the before, after times, in that order
|
||||
func ImportWindow() (time.Time, time.Time) {
|
||||
lock.RLock()
|
||||
defer lock.RUnlock()
|
||||
return globalConfig.importBefore, globalConfig.importAfter
|
||||
}
|
||||
|
||||
func FetchImagesDuringImport() bool {
|
||||
lock.RLock()
|
||||
defer lock.RUnlock()
|
||||
return globalConfig.fetchImageDuringImport
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ type DB interface {
|
|||
GetArtist(ctx context.Context, opts GetArtistOpts) (*models.Artist, error)
|
||||
GetAlbum(ctx context.Context, opts GetAlbumOpts) (*models.Album, error)
|
||||
GetTrack(ctx context.Context, opts GetTrackOpts) (*models.Track, error)
|
||||
GetArtistsForAlbum(ctx context.Context, id int32) ([]*models.Artist, error)
|
||||
GetArtistsForTrack(ctx context.Context, id int32) ([]*models.Artist, error)
|
||||
GetTopTracksPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Track], error)
|
||||
GetTopArtistsPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Artist], error)
|
||||
GetTopAlbumsPaginated(ctx context.Context, opts GetItemsOpts) (*PaginatedResponse[*models.Album], error)
|
||||
|
|
@ -48,6 +50,8 @@ type DB interface {
|
|||
SetPrimaryArtistAlias(ctx context.Context, id int32, alias string) error
|
||||
SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string) error
|
||||
SetPrimaryTrackAlias(ctx context.Context, id int32, alias string) error
|
||||
SetPrimaryAlbumArtist(ctx context.Context, id int32, artistId int32, value bool) error
|
||||
SetPrimaryTrackArtist(ctx context.Context, id int32, artistId int32, value bool) error
|
||||
// Delete
|
||||
DeleteArtist(ctx context.Context, id int32) error
|
||||
DeleteAlbum(ctx context.Context, id int32) error
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package psql
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -41,11 +42,11 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
|
|||
Column1: opts.Titles,
|
||||
})
|
||||
} else {
|
||||
return nil, errors.New("insufficient information to get album")
|
||||
return nil, errors.New("GetAlbum: insufficient information to get album")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetAlbum: %w", err)
|
||||
}
|
||||
|
||||
count, err := d.q.CountListensFromRelease(ctx, repository.CountListensFromReleaseParams{
|
||||
|
|
@ -54,7 +55,7 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
|
|||
ReleaseID: row.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetAlbum: CountListensFromRelease: %w", err)
|
||||
}
|
||||
|
||||
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
|
||||
|
|
@ -62,7 +63,7 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
|
|||
AlbumID: row.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err)
|
||||
}
|
||||
|
||||
return &models.Album{
|
||||
|
|
@ -87,17 +88,17 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al
|
|||
insertImage = &opts.Image
|
||||
}
|
||||
if len(opts.ArtistIDs) < 1 {
|
||||
return nil, errors.New("required parameter 'ArtistIDs' missing")
|
||||
return nil, errors.New("SaveAlbum: required parameter 'ArtistIDs' missing")
|
||||
}
|
||||
for _, aid := range opts.ArtistIDs {
|
||||
if aid == 0 {
|
||||
return nil, errors.New("none of 'ArtistIDs' may be 0")
|
||||
return nil, errors.New("SaveAlbum: none of 'ArtistIDs' may be 0")
|
||||
}
|
||||
}
|
||||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveAlbum: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
|
|
@ -109,7 +110,7 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al
|
|||
ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveAlbum: InsertRelease: %w", err)
|
||||
}
|
||||
for _, artistId := range opts.ArtistIDs {
|
||||
l.Debug().Msgf("Associating release '%s' to artist with ID %d", opts.Title, artistId)
|
||||
|
|
@ -118,7 +119,7 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al
|
|||
ReleaseID: r.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveAlbum: AssociateArtistToRelease: %w", err)
|
||||
}
|
||||
}
|
||||
l.Debug().Msgf("Saving canonical alias %s for release %d", opts.Title, r.ID)
|
||||
|
|
@ -130,11 +131,12 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al
|
|||
})
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Failed to save canonical alias for album %d", r.ID)
|
||||
return nil, fmt.Errorf("SaveAlbum: InsertReleaseAlias: %w", err)
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveAlbum: Commit: %w", err)
|
||||
}
|
||||
|
||||
return &models.Album{
|
||||
|
|
@ -151,7 +153,7 @@ func (d *Psql) AddArtistsToAlbum(ctx context.Context, opts db.AddArtistsToAlbumO
|
|||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return err
|
||||
return fmt.Errorf("AddArtistsToAlbum: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
|
|
@ -162,6 +164,7 @@ func (d *Psql) AddArtistsToAlbum(ctx context.Context, opts db.AddArtistsToAlbumO
|
|||
})
|
||||
if err != nil {
|
||||
l.Error().Err(err).Msgf("Failed to associate release %d with artist %d", opts.AlbumID, id)
|
||||
return fmt.Errorf("AddArtistsToAlbum: AssociateArtistToRelease: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
|
|
@ -175,7 +178,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error {
|
|||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return err
|
||||
return fmt.Errorf("UpdateAlbum: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
|
|
@ -186,7 +189,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error {
|
|||
MusicBrainzID: &opts.MusicBrainzID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("UpdateAlbum: UpdateReleaseMbzID: %w", err)
|
||||
}
|
||||
}
|
||||
if opts.Image != uuid.Nil {
|
||||
|
|
@ -197,7 +200,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error {
|
|||
ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("UpdateAlbum: UpdateReleaseImage: %w", err)
|
||||
}
|
||||
}
|
||||
if opts.VariousArtistsUpdate {
|
||||
|
|
@ -207,7 +210,7 @@ func (d *Psql) UpdateAlbum(ctx context.Context, opts db.UpdateAlbumOpts) error {
|
|||
VariousArtists: opts.VariousArtistsValue,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("UpdateAlbum: UpdateReleaseVariousArtists: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
|
|
@ -221,13 +224,13 @@ func (d *Psql) SaveAlbumAliases(ctx context.Context, id int32, aliases []string,
|
|||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return err
|
||||
return fmt.Errorf("SaveAlbumAliases: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
existing, err := qtx.GetAllReleaseAliases(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SaveAlbumAliases: GetAllReleaseAliases: %w", err)
|
||||
}
|
||||
for _, v := range existing {
|
||||
aliases = append(aliases, v.Alias)
|
||||
|
|
@ -235,7 +238,7 @@ func (d *Psql) SaveAlbumAliases(ctx context.Context, id int32, aliases []string,
|
|||
utils.Unique(&aliases)
|
||||
for _, alias := range aliases {
|
||||
if strings.TrimSpace(alias) == "" {
|
||||
return errors.New("aliases cannot be blank")
|
||||
return errors.New("SaveAlbumAliases: aliases cannot be blank")
|
||||
}
|
||||
err = qtx.InsertReleaseAlias(ctx, repository.InsertReleaseAliasParams{
|
||||
Alias: strings.TrimSpace(alias),
|
||||
|
|
@ -244,7 +247,7 @@ func (d *Psql) SaveAlbumAliases(ctx context.Context, id int32, aliases []string,
|
|||
IsPrimary: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SaveAlbumAliases: InsertReleaseAlias: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
|
|
@ -263,7 +266,7 @@ func (d *Psql) DeleteAlbumAlias(ctx context.Context, id int32, alias string) err
|
|||
func (d *Psql) GetAllAlbumAliases(ctx context.Context, id int32) ([]models.Alias, error) {
|
||||
rows, err := d.q.GetAllReleaseAliases(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetAllAlbumAliases: GetAllReleaseAliases: %w", err)
|
||||
}
|
||||
aliases := make([]models.Alias, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -285,14 +288,14 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string)
|
|||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return err
|
||||
return fmt.Errorf("SetPrimaryAlbumAlias: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
// get all aliases
|
||||
aliases, err := qtx.GetAllReleaseAliases(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SetPrimaryAlbumAlias: GetAllReleaseAliases: %w", err)
|
||||
}
|
||||
primary := ""
|
||||
exists := false
|
||||
|
|
@ -309,7 +312,7 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string)
|
|||
return nil
|
||||
}
|
||||
if !exists {
|
||||
return errors.New("alias does not exist")
|
||||
return errors.New("SetPrimaryAlbumAlias: alias does not exist")
|
||||
}
|
||||
err = qtx.SetReleaseAliasPrimaryStatus(ctx, repository.SetReleaseAliasPrimaryStatusParams{
|
||||
ReleaseID: id,
|
||||
|
|
@ -317,7 +320,7 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string)
|
|||
IsPrimary: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SetPrimaryAlbumAlias: SetReleaseAliasPrimaryStatus: %w", err)
|
||||
}
|
||||
err = qtx.SetReleaseAliasPrimaryStatus(ctx, repository.SetReleaseAliasPrimaryStatusParams{
|
||||
ReleaseID: id,
|
||||
|
|
@ -325,7 +328,61 @@ func (d *Psql) SetPrimaryAlbumAlias(ctx context.Context, id int32, alias string)
|
|||
IsPrimary: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SetPrimaryAlbumAlias: SetReleaseAliasPrimaryStatus: %w", err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (d *Psql) SetPrimaryAlbumArtist(ctx context.Context, id int32, artistId int32, value bool) error {
|
||||
l := logger.FromContext(ctx)
|
||||
if id == 0 {
|
||||
return errors.New("artist id not specified")
|
||||
}
|
||||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return fmt.Errorf("SetPrimaryAlbumArtist: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
// get all artists
|
||||
artists, err := qtx.GetReleaseArtists(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SetPrimaryAlbumArtist: GetReleaseArtists: %w", err)
|
||||
}
|
||||
var primary int32
|
||||
for _, v := range artists {
|
||||
// i dont get it??? is_primary is not a nullable column??? why use pgtype.Bool???
|
||||
// why not just use boolean??? is sqlc stupid??? am i stupid???????
|
||||
if v.IsPrimary.Valid && v.IsPrimary.Bool {
|
||||
primary = v.ID
|
||||
}
|
||||
}
|
||||
if value && primary == artistId {
|
||||
// no-op
|
||||
return nil
|
||||
}
|
||||
l.Debug().Msgf("Marking artist with id %d as 'primary = %v' on album with id %d", artistId, value, id)
|
||||
err = qtx.UpdateReleasePrimaryArtist(ctx, repository.UpdateReleasePrimaryArtistParams{
|
||||
ReleaseID: id,
|
||||
ArtistID: artistId,
|
||||
IsPrimary: value,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("SetPrimaryAlbumArtist: UpdateReleasePrimaryArtist: %w", err)
|
||||
}
|
||||
if value && primary != 0 {
|
||||
// if we were marking a new one as primary and there was already one marked as primary,
|
||||
// unmark that one as there can only be one
|
||||
l.Debug().Msgf("Unmarking artist with id %d as primary on album with id %d", primary, id)
|
||||
err = qtx.UpdateReleasePrimaryArtist(ctx, repository.UpdateReleasePrimaryArtistParams{
|
||||
ReleaseID: id,
|
||||
ArtistID: primary,
|
||||
IsPrimary: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("SetPrimaryAlbumArtist: UpdateReleasePrimaryArtist: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package psql
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -23,7 +24,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
|||
l.Debug().Msgf("Fetching artist from DB with id %d", opts.ID)
|
||||
row, err := d.q.GetArtist(ctx, opts.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetArtist: GetArtist by ID: %w", err)
|
||||
}
|
||||
count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{
|
||||
ListenedAt: time.Unix(0, 0),
|
||||
|
|
@ -31,14 +32,14 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
|||
ArtistID: row.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
|
||||
}
|
||||
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
|
||||
Period: db.PeriodAllTime,
|
||||
ArtistID: row.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
|
||||
}
|
||||
return &models.Artist{
|
||||
ID: row.ID,
|
||||
|
|
@ -53,7 +54,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
|||
l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID)
|
||||
row, err := d.q.GetArtistByMbzID(ctx, &opts.MusicBrainzID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetArtist: GetArtistByMbzID: %w", err)
|
||||
}
|
||||
count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{
|
||||
ListenedAt: time.Unix(0, 0),
|
||||
|
|
@ -61,14 +62,14 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
|||
ArtistID: row.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
|
||||
}
|
||||
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
|
||||
Period: db.PeriodAllTime,
|
||||
ArtistID: row.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
|
||||
}
|
||||
return &models.Artist{
|
||||
ID: row.ID,
|
||||
|
|
@ -83,7 +84,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
|||
l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name)
|
||||
row, err := d.q.GetArtistByName(ctx, opts.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetArtist: GetArtistByName: %w", err)
|
||||
}
|
||||
count, err := d.q.CountListensFromArtist(ctx, repository.CountListensFromArtistParams{
|
||||
ListenedAt: time.Unix(0, 0),
|
||||
|
|
@ -91,14 +92,14 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
|||
ArtistID: row.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetArtist: CountListensFromArtist: %w", err)
|
||||
}
|
||||
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
|
||||
Period: db.PeriodAllTime,
|
||||
ArtistID: row.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
|
||||
}
|
||||
return &models.Artist{
|
||||
ID: row.ID,
|
||||
|
|
@ -118,35 +119,36 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
|
|||
func (d *Psql) SaveArtistAliases(ctx context.Context, id int32, aliases []string, source string) error {
|
||||
l := logger.FromContext(ctx)
|
||||
if id == 0 {
|
||||
return errors.New("artist id not specified")
|
||||
return errors.New("SaveArtistAliases: artist id not specified")
|
||||
}
|
||||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return err
|
||||
return fmt.Errorf("SaveArtistAliases: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
existing, err := qtx.GetAllArtistAliases(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SaveArtistAliases: GetAllArtistAliases: %w", err)
|
||||
}
|
||||
for _, v := range existing {
|
||||
aliases = append(aliases, v.Alias)
|
||||
}
|
||||
utils.Unique(&aliases)
|
||||
for _, alias := range aliases {
|
||||
if strings.TrimSpace(alias) == "" {
|
||||
return errors.New("aliases cannot be blank")
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias == "" {
|
||||
return errors.New("SaveArtistAliases: aliases cannot be blank")
|
||||
}
|
||||
err = qtx.InsertArtistAlias(ctx, repository.InsertArtistAliasParams{
|
||||
Alias: strings.TrimSpace(alias),
|
||||
Alias: alias,
|
||||
ArtistID: id,
|
||||
Source: source,
|
||||
IsPrimary: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SaveArtistAliases: InsertArtistAlias: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
|
|
@ -170,13 +172,13 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models.
|
|||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveArtist: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
opts.Name = strings.TrimSpace(opts.Name)
|
||||
if opts.Name == "" {
|
||||
return nil, errors.New("name must not be blank")
|
||||
return nil, errors.New("SaveArtist: name must not be blank")
|
||||
}
|
||||
l.Debug().Msgf("Inserting artist '%s' into DB", opts.Name)
|
||||
a, err := qtx.InsertArtist(ctx, repository.InsertArtistParams{
|
||||
|
|
@ -185,7 +187,7 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models.
|
|||
ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveArtist: InsertArtist: %w", err)
|
||||
}
|
||||
l.Debug().Msgf("Inserting canonical alias '%s' into DB for artist with id %d", opts.Name, a.ID)
|
||||
err = qtx.InsertArtistAlias(ctx, repository.InsertArtistAliasParams{
|
||||
|
|
@ -195,13 +197,13 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models.
|
|||
IsPrimary: true,
|
||||
})
|
||||
if err != nil {
|
||||
l.Error().Err(err).Msgf("Error inserting canonical alias for artist '%s'", opts.Name)
|
||||
return nil, err
|
||||
l.Err(err).Msgf("SaveArtist: error inserting canonical alias for artist '%s'", opts.Name)
|
||||
return nil, fmt.Errorf("SaveArtist: InsertArtistAlias: %w", err)
|
||||
}
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to commit insert artist transaction")
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveArtist: Commit: %w", err)
|
||||
}
|
||||
artist := &models.Artist{
|
||||
ID: a.ID,
|
||||
|
|
@ -214,7 +216,7 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models.
|
|||
l.Debug().Msgf("Inserting aliases '%v' into DB for artist '%s'", opts.Aliases, opts.Name)
|
||||
err = d.SaveArtistAliases(ctx, a.ID, opts.Aliases, "MusicBrainz")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveArtist: SaveArtistAliases: %w", err)
|
||||
}
|
||||
artist.Aliases = opts.Aliases
|
||||
}
|
||||
|
|
@ -224,12 +226,12 @@ func (d *Psql) SaveArtist(ctx context.Context, opts db.SaveArtistOpts) (*models.
|
|||
func (d *Psql) UpdateArtist(ctx context.Context, opts db.UpdateArtistOpts) error {
|
||||
l := logger.FromContext(ctx)
|
||||
if opts.ID == 0 {
|
||||
return errors.New("artist id not specified")
|
||||
return errors.New("UpdateArtist: artist id not specified")
|
||||
}
|
||||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return err
|
||||
return fmt.Errorf("UpdateArtist: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
|
|
@ -240,7 +242,7 @@ func (d *Psql) UpdateArtist(ctx context.Context, opts db.UpdateArtistOpts) error
|
|||
MusicBrainzID: &opts.MusicBrainzID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("UpdateArtist: UpdateArtistMbzID: %w", err)
|
||||
}
|
||||
}
|
||||
if opts.Image != uuid.Nil {
|
||||
|
|
@ -251,10 +253,15 @@ func (d *Psql) UpdateArtist(ctx context.Context, opts db.UpdateArtistOpts) error
|
|||
ImageSource: pgtype.Text{String: opts.ImageSrc, Valid: opts.ImageSrc != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("UpdateArtist: UpdateArtistImage: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to commit update artist transaction")
|
||||
return fmt.Errorf("UpdateArtist: Commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Psql) DeleteArtistAlias(ctx context.Context, id int32, alias string) error {
|
||||
|
|
@ -263,10 +270,11 @@ func (d *Psql) DeleteArtistAlias(ctx context.Context, id int32, alias string) er
|
|||
Alias: alias,
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Psql) GetAllArtistAliases(ctx context.Context, id int32) ([]models.Alias, error) {
|
||||
rows, err := d.q.GetAllArtistAliases(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetAllArtistAliases: %w", err)
|
||||
}
|
||||
aliases := make([]models.Alias, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -283,19 +291,18 @@ func (d *Psql) GetAllArtistAliases(ctx context.Context, id int32) ([]models.Alia
|
|||
func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string) error {
|
||||
l := logger.FromContext(ctx)
|
||||
if id == 0 {
|
||||
return errors.New("artist id not specified")
|
||||
return errors.New("SetPrimaryArtistAlias: artist id not specified")
|
||||
}
|
||||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return err
|
||||
return fmt.Errorf("SetPrimaryArtistAlias: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
// get all aliases
|
||||
aliases, err := qtx.GetAllArtistAliases(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SetPrimaryArtistAlias: GetAllArtistAliases: %w", err)
|
||||
}
|
||||
primary := ""
|
||||
exists := false
|
||||
|
|
@ -308,11 +315,10 @@ func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string
|
|||
}
|
||||
}
|
||||
if primary == alias {
|
||||
// no-op rename
|
||||
return nil
|
||||
}
|
||||
if !exists {
|
||||
return errors.New("alias does not exist")
|
||||
return errors.New("SetPrimaryArtistAlias: alias does not exist")
|
||||
}
|
||||
err = qtx.SetArtistAliasPrimaryStatus(ctx, repository.SetArtistAliasPrimaryStatusParams{
|
||||
ArtistID: id,
|
||||
|
|
@ -320,7 +326,7 @@ func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string
|
|||
IsPrimary: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SetPrimaryArtistAlias: SetArtistAliasPrimaryStatus (primary): %w", err)
|
||||
}
|
||||
err = qtx.SetArtistAliasPrimaryStatus(ctx, repository.SetArtistAliasPrimaryStatusParams{
|
||||
ArtistID: id,
|
||||
|
|
@ -328,7 +334,57 @@ func (d *Psql) SetPrimaryArtistAlias(ctx context.Context, id int32, alias string
|
|||
IsPrimary: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SetPrimaryArtistAlias: SetArtistAliasPrimaryStatus (previous primary): %w", err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to commit transaction")
|
||||
return fmt.Errorf("SetPrimaryArtistAlias: Commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (d *Psql) GetArtistsForAlbum(ctx context.Context, id int32) ([]*models.Artist, error) {
|
||||
l := logger.FromContext(ctx)
|
||||
l.Debug().Msgf("Fetching artists for album ID %d", id)
|
||||
|
||||
rows, err := d.q.GetReleaseArtists(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetArtistsForAlbum: %w", err)
|
||||
}
|
||||
|
||||
artists := make([]*models.Artist, len(rows))
|
||||
for i, row := range rows {
|
||||
artists[i] = &models.Artist{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
MbzID: row.MusicBrainzID,
|
||||
Image: row.Image,
|
||||
IsPrimary: row.IsPrimary.Valid && row.IsPrimary.Bool,
|
||||
}
|
||||
}
|
||||
|
||||
return artists, nil
|
||||
}
|
||||
|
||||
func (d *Psql) GetArtistsForTrack(ctx context.Context, id int32) ([]*models.Artist, error) {
|
||||
l := logger.FromContext(ctx)
|
||||
l.Debug().Msgf("Fetching artists for track ID %d", id)
|
||||
|
||||
rows, err := d.q.GetTrackArtists(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetArtistsForTrack: %w", err)
|
||||
}
|
||||
|
||||
artists := make([]*models.Artist, len(rows))
|
||||
for i, row := range rows {
|
||||
artists[i] = &models.Artist{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
MbzID: row.MusicBrainzID,
|
||||
Image: row.Image,
|
||||
IsPrimary: row.IsPrimary.Valid && row.IsPrimary.Bool,
|
||||
}
|
||||
}
|
||||
|
||||
return artists, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package psql
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gabehf/koito/internal/db"
|
||||
|
|
@ -17,10 +18,11 @@ func (p *Psql) CountListens(ctx context.Context, period db.Period) (int64, error
|
|||
ListenedAt_2: t2,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, fmt.Errorf("CountListens: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error) {
|
||||
t2 := time.Now()
|
||||
t1 := db.StartTimeFromPeriod(period)
|
||||
|
|
@ -29,10 +31,11 @@ func (p *Psql) CountTracks(ctx context.Context, period db.Period) (int64, error)
|
|||
ListenedAt_2: t2,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, fmt.Errorf("CountTracks: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error) {
|
||||
t2 := time.Now()
|
||||
t1 := db.StartTimeFromPeriod(period)
|
||||
|
|
@ -41,10 +44,11 @@ func (p *Psql) CountAlbums(ctx context.Context, period db.Period) (int64, error)
|
|||
ListenedAt_2: t2,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, fmt.Errorf("CountAlbums: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error) {
|
||||
t2 := time.Now()
|
||||
t1 := db.StartTimeFromPeriod(period)
|
||||
|
|
@ -53,10 +57,11 @@ func (p *Psql) CountArtists(ctx context.Context, period db.Period) (int64, error
|
|||
ListenedAt_2: t2,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, fmt.Errorf("CountArtists: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64, error) {
|
||||
t2 := time.Now()
|
||||
t1 := db.StartTimeFromPeriod(period)
|
||||
|
|
@ -65,10 +70,11 @@ func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64,
|
|||
ListenedAt_2: t2,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, fmt.Errorf("CountTimeListened: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) {
|
||||
t2 := time.Now()
|
||||
t1 := db.StartTimeFromPeriod(opts.Period)
|
||||
|
|
@ -80,7 +86,7 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened
|
|||
ArtistID: opts.ArtistID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, fmt.Errorf("CountTimeListenedToItem (Artist): %w", err)
|
||||
}
|
||||
return count, nil
|
||||
} else if opts.AlbumID > 0 {
|
||||
|
|
@ -90,10 +96,9 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened
|
|||
ReleaseID: opts.AlbumID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, fmt.Errorf("CountTimeListenedToItem (Album): %w", err)
|
||||
}
|
||||
return count, nil
|
||||
|
||||
} else if opts.TrackID > 0 {
|
||||
count, err := p.q.CountTimeListenedToTrack(ctx, repository.CountTimeListenedToTrackParams{
|
||||
ListenedAt: t1,
|
||||
|
|
@ -101,9 +106,9 @@ func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListened
|
|||
ID: opts.TrackID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, fmt.Errorf("CountTimeListenedToItem (Track): %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
return 0, errors.New("an id must be provided")
|
||||
return 0, errors.New("CountTimeListenedToItem: an id must be provided")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gabehf/koito/internal/logger"
|
||||
"github.com/gabehf/koito/internal/models"
|
||||
|
|
@ -15,15 +16,15 @@ import (
|
|||
func (d *Psql) ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error) {
|
||||
_, err := d.q.GetReleaseByImageID(ctx, &image)
|
||||
if err == nil {
|
||||
return true, err
|
||||
return true, nil
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, err
|
||||
return false, fmt.Errorf("ImageHasAssociation: GetReleaseByImageID: %w", err)
|
||||
}
|
||||
_, err = d.q.GetArtistByImage(ctx, &image)
|
||||
if err == nil {
|
||||
return true, err
|
||||
return true, nil
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, err
|
||||
return false, fmt.Errorf("ImageHasAssociation: GetArtistByImage: %w", err)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
|
@ -31,15 +32,15 @@ func (d *Psql) ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool,
|
|||
func (d *Psql) GetImageSource(ctx context.Context, image uuid.UUID) (string, error) {
|
||||
r, err := d.q.GetReleaseByImageID(ctx, &image)
|
||||
if err == nil {
|
||||
return r.ImageSource.String, err
|
||||
return r.ImageSource.String, nil
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", err
|
||||
return "", fmt.Errorf("GetImageSource: GetReleaseByImageID: %w", err)
|
||||
}
|
||||
rr, err := d.q.GetArtistByImage(ctx, &image)
|
||||
if err == nil {
|
||||
return rr.ImageSource.String, err
|
||||
return rr.ImageSource.String, nil
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", err
|
||||
return "", fmt.Errorf("GetImageSource: GetArtistByImage: %w", err)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
|
@ -51,14 +52,13 @@ func (d *Psql) AlbumsWithoutImages(ctx context.Context, from int32) ([]*models.A
|
|||
ID: from,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("AlbumsWithoutImages: GetReleasesWithoutImages: %w", err)
|
||||
}
|
||||
albums := make([]*models.Album, len(rows))
|
||||
for i, row := range rows {
|
||||
artists := make([]models.SimpleArtist, 0)
|
||||
err = json.Unmarshal(row.Artists, &artists)
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Error unmarshalling artists for release group with id %d", row.ID)
|
||||
var artists []models.SimpleArtist
|
||||
if err := json.Unmarshal(row.Artists, &artists); err != nil {
|
||||
l.Err(err).Msgf("AlbumsWithoutImages: error unmarshalling artists for release group with id %d", row.ID)
|
||||
artists = nil
|
||||
}
|
||||
albums[i] = &models.Album{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gabehf/koito/internal/db"
|
||||
|
|
@ -18,7 +19,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
|
|||
offset := (opts.Page - 1) * opts.Limit
|
||||
t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListensPaginated: %w", err)
|
||||
}
|
||||
if opts.Month == 0 && opts.Year == 0 {
|
||||
// use period, not date range
|
||||
|
|
@ -41,7 +42,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
|
|||
ID: int32(opts.TrackID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListensPaginated: GetLastListensFromTrackPaginated: %w", err)
|
||||
}
|
||||
listens = make([]*models.Listen, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -54,7 +55,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
|
|||
}
|
||||
err = json.Unmarshal(row.Artists, &t.Track.Artists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err)
|
||||
}
|
||||
listens[i] = t
|
||||
}
|
||||
|
|
@ -64,7 +65,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
|
|||
TrackID: int32(opts.TrackID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListensPaginated: CountListensFromTrack: %w", err)
|
||||
}
|
||||
} else if opts.AlbumID > 0 {
|
||||
l.Debug().Msgf("Fetching %d listens with period %s on page %d from range %v to %v",
|
||||
|
|
@ -77,7 +78,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
|
|||
ReleaseID: int32(opts.AlbumID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListensPaginated: GetLastListensFromReleasePaginated: %w", err)
|
||||
}
|
||||
listens = make([]*models.Listen, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -90,7 +91,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
|
|||
}
|
||||
err = json.Unmarshal(row.Artists, &t.Track.Artists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err)
|
||||
}
|
||||
listens[i] = t
|
||||
}
|
||||
|
|
@ -100,7 +101,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
|
|||
ReleaseID: int32(opts.AlbumID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListensPaginated: CountListensFromRelease: %w", err)
|
||||
}
|
||||
} else if opts.ArtistID > 0 {
|
||||
l.Debug().Msgf("Fetching %d listens with period %s on page %d from range %v to %v",
|
||||
|
|
@ -113,7 +114,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
|
|||
ArtistID: int32(opts.ArtistID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListensPaginated: GetLastListensFromArtistPaginated: %w", err)
|
||||
}
|
||||
listens = make([]*models.Listen, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -126,7 +127,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
|
|||
}
|
||||
err = json.Unmarshal(row.Artists, &t.Track.Artists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err)
|
||||
}
|
||||
listens[i] = t
|
||||
}
|
||||
|
|
@ -136,7 +137,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
|
|||
ArtistID: int32(opts.ArtistID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListensPaginated: CountListensFromArtist: %w", err)
|
||||
}
|
||||
} else {
|
||||
l.Debug().Msgf("Fetching %d listens with period %s on page %d from range %v to %v",
|
||||
|
|
@ -148,7 +149,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
|
|||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListensPaginated: GetLastListensPaginated: %w", err)
|
||||
}
|
||||
listens = make([]*models.Listen, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -161,7 +162,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
|
|||
}
|
||||
err = json.Unmarshal(row.Artists, &t.Track.Artists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListensPaginated: Unmarshal: %w", err)
|
||||
}
|
||||
listens[i] = t
|
||||
}
|
||||
|
|
@ -170,7 +171,7 @@ func (d *Psql) GetListensPaginated(ctx context.Context, opts db.GetItemsOpts) (*
|
|||
ListenedAt_2: t2,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListensPaginated: CountListens: %w", err)
|
||||
}
|
||||
l.Debug().Msgf("Database responded with %d tracks out of a total %d", len(rows), count)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package psql
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gabehf/koito/internal/db"
|
||||
"github.com/gabehf/koito/internal/logger"
|
||||
|
|
@ -30,7 +31,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
|
|||
ReleaseID: opts.AlbumID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListenActivity: ListenActivityForRelease: %w", err)
|
||||
}
|
||||
listenActivity = make([]db.ListenActivityItem, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -51,7 +52,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
|
|||
ArtistID: opts.ArtistID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListenActivity: ListenActivityForArtist: %w", err)
|
||||
}
|
||||
listenActivity = make([]db.ListenActivityItem, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -72,7 +73,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
|
|||
ID: opts.TrackID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListenActivity: ListenActivityForTrack: %w", err)
|
||||
}
|
||||
listenActivity = make([]db.ListenActivityItem, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -92,7 +93,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts
|
|||
Column3: stepToInterval(opts.Step),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetListenActivity: ListenActivity: %w", err)
|
||||
}
|
||||
listenActivity = make([]db.ListenActivityItem, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage
|
|||
|
||||
fromArtists, err := qtx.GetReleaseArtists(ctx, fromId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("MergeTracks: GetReleaseArtists: %w", err)
|
||||
return fmt.Errorf("MergeAlbums: GetReleaseArtists: %w", err)
|
||||
}
|
||||
|
||||
err = qtx.UpdateReleaseForAll(ctx, repository.UpdateReleaseForAllParams{
|
||||
|
|
|
|||
|
|
@ -34,34 +34,34 @@ func New() (*Psql, error) {
|
|||
|
||||
config, err := pgxpool.ParseConfig(cfg.DatabaseUrl())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse pgx config: %w", err)
|
||||
return nil, fmt.Errorf("psql.New: failed to parse pgx config: %w", err)
|
||||
}
|
||||
|
||||
config.ConnConfig.ConnectTimeout = 15 * time.Second
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create pgx pool: %w", err)
|
||||
return nil, fmt.Errorf("psql.New: failed to create pgx pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("database not reachable: %w", err)
|
||||
return nil, fmt.Errorf("psql.New: database not reachable: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := sql.Open("pgx", cfg.DatabaseUrl())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open db for migrations: %w", err)
|
||||
return nil, fmt.Errorf("psql.New: failed to open db for migrations: %w", err)
|
||||
}
|
||||
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unable to get caller info")
|
||||
return nil, fmt.Errorf("psql.New: unable to get caller info")
|
||||
}
|
||||
migrationsPath := filepath.Join(filepath.Dir(filename), "..", "..", "..", "db", "migrations")
|
||||
|
||||
if err := goose.Up(sqlDB, migrationsPath); err != nil {
|
||||
return nil, fmt.Errorf("goose failed: %w", err)
|
||||
return nil, fmt.Errorf("psql.New: goose failed: %w", err)
|
||||
}
|
||||
_ = sqlDB.Close()
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package psql
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/gabehf/koito/internal/models"
|
||||
"github.com/gabehf/koito/internal/repository"
|
||||
|
|
@ -19,7 +20,7 @@ func (d *Psql) SearchArtists(ctx context.Context, q string) ([]*models.Artist, e
|
|||
Limit: searchItemLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SearchArtist: SearchArtistsBySubstring: %w", err)
|
||||
}
|
||||
ret := make([]*models.Artist, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -37,7 +38,7 @@ func (d *Psql) SearchArtists(ctx context.Context, q string) ([]*models.Artist, e
|
|||
Limit: searchItemLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SearchArtist: SearchArtists: %w", err)
|
||||
}
|
||||
ret := make([]*models.Artist, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -59,7 +60,7 @@ func (d *Psql) SearchAlbums(ctx context.Context, q string) ([]*models.Album, err
|
|||
Limit: searchItemLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SearchAlbums: SearchReleasesBySubstring: %w", err)
|
||||
}
|
||||
ret := make([]*models.Album, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -72,7 +73,7 @@ func (d *Psql) SearchAlbums(ctx context.Context, q string) ([]*models.Album, err
|
|||
}
|
||||
err = json.Unmarshal(row.Artists, &ret[i].Artists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SearchAlbums: Unmarshal: %w", err)
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
|
|
@ -82,7 +83,7 @@ func (d *Psql) SearchAlbums(ctx context.Context, q string) ([]*models.Album, err
|
|||
Limit: searchItemLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SearchAlbums: SearchReleases: %w", err)
|
||||
}
|
||||
ret := make([]*models.Album, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -95,7 +96,7 @@ func (d *Psql) SearchAlbums(ctx context.Context, q string) ([]*models.Album, err
|
|||
}
|
||||
err = json.Unmarshal(row.Artists, &ret[i].Artists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SearchAlbums: Unmarshal: %w", err)
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
|
|
@ -109,7 +110,7 @@ func (d *Psql) SearchTracks(ctx context.Context, q string) ([]*models.Track, err
|
|||
Limit: searchItemLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SearchTracks: SearchTracksBySubstring: %w", err)
|
||||
}
|
||||
ret := make([]*models.Track, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -121,7 +122,7 @@ func (d *Psql) SearchTracks(ctx context.Context, q string) ([]*models.Track, err
|
|||
}
|
||||
err = json.Unmarshal(row.Artists, &ret[i].Artists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SearchTracks: Unmarshal: %w", err)
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
|
|
@ -131,7 +132,7 @@ func (d *Psql) SearchTracks(ctx context.Context, q string) ([]*models.Track, err
|
|||
Limit: searchItemLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SearchTracks: SearchTracks: %w", err)
|
||||
}
|
||||
ret := make([]*models.Track, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -143,7 +144,7 @@ func (d *Psql) SearchTracks(ctx context.Context, q string) ([]*models.Track, err
|
|||
}
|
||||
err = json.Unmarshal(row.Artists, &ret[i].Artists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SearchTracks: Unmarshal: %w", err)
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package psql
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gabehf/koito/internal/models"
|
||||
|
|
@ -19,7 +20,7 @@ func (d *Psql) SaveSession(ctx context.Context, userID int32, expiresAt time.Tim
|
|||
Persistent: persistent,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveSession: InsertSession: %w", err)
|
||||
}
|
||||
return &models.Session{
|
||||
ID: session.ID,
|
||||
|
|
@ -47,7 +48,7 @@ func (d *Psql) GetUserBySession(ctx context.Context, sessionId uuid.UUID) (*mode
|
|||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveSession: GetUserBySession: %w", err)
|
||||
}
|
||||
|
||||
return &models.User{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package psql
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gabehf/koito/internal/db"
|
||||
|
|
@ -17,7 +18,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
offset := (opts.Page - 1) * opts.Limit
|
||||
t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopAlbumsPaginated: %w", err)
|
||||
}
|
||||
if opts.Month == 0 && opts.Year == 0 {
|
||||
// use period, not date range
|
||||
|
|
@ -43,7 +44,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
ListenedAt_2: t2,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopAlbumsPaginated: GetTopReleasesFromArtist: %w", err)
|
||||
}
|
||||
rgs = make([]*models.Album, len(rows))
|
||||
l.Debug().Msgf("Database responded with %d items", len(rows))
|
||||
|
|
@ -52,7 +53,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
err = json.Unmarshal(v.Artists, &artists)
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Error unmarshalling artists for release group with id %d", v.ID)
|
||||
artists = nil
|
||||
return nil, fmt.Errorf("GetTopAlbumsPaginated: Unmarshal: %w", err)
|
||||
}
|
||||
rgs[i] = &models.Album{
|
||||
ID: v.ID,
|
||||
|
|
@ -66,7 +67,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
}
|
||||
count, err = d.q.CountReleasesFromArtist(ctx, int32(opts.ArtistID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopAlbumsPaginated: CountReleasesFromArtist: %w", err)
|
||||
}
|
||||
} else {
|
||||
l.Debug().Msgf("Fetching top %d albums with period %s on page %d from range %v to %v",
|
||||
|
|
@ -78,7 +79,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopAlbumsPaginated: GetTopReleasesPaginated: %w", err)
|
||||
}
|
||||
rgs = make([]*models.Album, len(rows))
|
||||
l.Debug().Msgf("Database responded with %d items", len(rows))
|
||||
|
|
@ -87,7 +88,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
err = json.Unmarshal(row.Artists, &artists)
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Error unmarshalling artists for release group with id %d", row.ID)
|
||||
artists = nil
|
||||
return nil, fmt.Errorf("GetTopAlbumsPaginated: Unmarshal: %w", err)
|
||||
}
|
||||
t := &models.Album{
|
||||
Title: row.Title,
|
||||
|
|
@ -105,7 +106,7 @@ func (d *Psql) GetTopAlbumsPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
ListenedAt_2: t2,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopAlbumsPaginated: CountTopReleases: %w", err)
|
||||
}
|
||||
l.Debug().Msgf("Database responded with %d albums out of a total %d", len(rows), count)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package psql
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gabehf/koito/internal/db"
|
||||
|
|
@ -16,7 +17,7 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
offset := (opts.Page - 1) * opts.Limit
|
||||
t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopArtistsPaginated: %w", err)
|
||||
}
|
||||
if opts.Month == 0 && opts.Year == 0 {
|
||||
// use period, not date range
|
||||
|
|
@ -35,7 +36,7 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopArtistsPaginated: GetTopArtistsPaginated: %w", err)
|
||||
}
|
||||
rgs := make([]*models.Artist, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -53,7 +54,7 @@ func (d *Psql) GetTopArtistsPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
ListenedAt_2: t2,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopArtistsPaginated: CountTopArtists: %w", err)
|
||||
}
|
||||
l.Debug().Msgf("Database responded with %d artists out of a total %d", len(rows), count)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package psql
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gabehf/koito/internal/db"
|
||||
|
|
@ -17,7 +18,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
offset := (opts.Page - 1) * opts.Limit
|
||||
t1, t2, err := utils.DateRange(opts.Week, opts.Month, opts.Year)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopTracksPaginated: %w", err)
|
||||
}
|
||||
if opts.Month == 0 && opts.Year == 0 {
|
||||
// use period, not date range
|
||||
|
|
@ -40,7 +41,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
ReleaseID: int32(opts.AlbumID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopTracksPaginated: GetTopTracksInReleasePaginated: %w", err)
|
||||
}
|
||||
tracks = make([]*models.Track, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -48,7 +49,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
err = json.Unmarshal(row.Artists, &artists)
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Error unmarshalling artists for track with id %d", row.ID)
|
||||
artists = nil
|
||||
return nil, fmt.Errorf("GetTopTracksPaginated: Unmarshal: %w", err)
|
||||
}
|
||||
t := &models.Track{
|
||||
Title: row.Title,
|
||||
|
|
@ -80,7 +81,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
ArtistID: int32(opts.ArtistID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopTracksPaginated: GetTopTracksByArtistPaginated: %w", err)
|
||||
}
|
||||
tracks = make([]*models.Track, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -88,7 +89,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
err = json.Unmarshal(row.Artists, &artists)
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Error unmarshalling artists for track with id %d", row.ID)
|
||||
artists = nil
|
||||
return nil, fmt.Errorf("GetTopTracksPaginated: Unmarshal: %w", err)
|
||||
}
|
||||
t := &models.Track{
|
||||
Title: row.Title,
|
||||
|
|
@ -107,7 +108,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
ArtistID: int32(opts.ArtistID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopTracksPaginated: CountTopTracksByArtist: %w", err)
|
||||
}
|
||||
} else {
|
||||
l.Debug().Msgf("Fetching top %d tracks with period %s on page %d from range %v to %v",
|
||||
|
|
@ -119,7 +120,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopTracksPaginated: GetTopTracksPaginated: %w", err)
|
||||
}
|
||||
tracks = make([]*models.Track, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -127,7 +128,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
err = json.Unmarshal(row.Artists, &artists)
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Error unmarshalling artists for track with id %d", row.ID)
|
||||
artists = nil
|
||||
return nil, fmt.Errorf("GetTopTracksPaginated: Unmarshal: %w", err)
|
||||
}
|
||||
t := &models.Track{
|
||||
Title: row.Title,
|
||||
|
|
@ -145,7 +146,7 @@ func (d *Psql) GetTopTracksPaginated(ctx context.Context, opts db.GetItemsOpts)
|
|||
ListenedAt_2: t2,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTopTracksPaginated: CountTopTracks: %w", err)
|
||||
}
|
||||
l.Debug().Msgf("Database responded with %d tracks out of a total %d", len(rows), count)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package psql
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -23,7 +24,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
|
|||
l.Debug().Msgf("Fetching track from DB with id %d", opts.ID)
|
||||
t, err := d.q.GetTrack(ctx, opts.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTrack: GetTrack By ID: %w", err)
|
||||
}
|
||||
track = models.Track{
|
||||
ID: t.ID,
|
||||
|
|
@ -37,7 +38,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
|
|||
l.Debug().Msgf("Fetching track from DB with MusicBrainz ID %s", opts.MusicBrainzID)
|
||||
t, err := d.q.GetTrackByMbzID(ctx, &opts.MusicBrainzID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTrack: GetTrackByMbzID: %w", err)
|
||||
}
|
||||
track = models.Track{
|
||||
ID: t.ID,
|
||||
|
|
@ -53,7 +54,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
|
|||
Column2: opts.ArtistIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTrack: GetTrackByTitleAndArtists: %w", err)
|
||||
}
|
||||
track = models.Track{
|
||||
ID: t.ID,
|
||||
|
|
@ -63,7 +64,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
|
|||
Duration: t.Duration,
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("insufficient information to get track")
|
||||
return nil, errors.New("GetTrack: insufficient information to get track")
|
||||
}
|
||||
|
||||
count, err := d.q.CountListensFromTrack(ctx, repository.CountListensFromTrackParams{
|
||||
|
|
@ -72,7 +73,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
|
|||
TrackID: track.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTrack: CountListensFromTrack: %w", err)
|
||||
}
|
||||
|
||||
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
|
||||
|
|
@ -80,7 +81,7 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
|
|||
TrackID: track.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err)
|
||||
}
|
||||
|
||||
track.ListenCount = count
|
||||
|
|
@ -97,20 +98,20 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
|
|||
insertMbzID = &opts.RecordingMbzID
|
||||
}
|
||||
if len(opts.ArtistIDs) < 1 {
|
||||
return nil, errors.New("required parameter 'ArtistIDs' missing")
|
||||
return nil, errors.New("SaveTrack: required parameter 'ArtistIDs' missing")
|
||||
}
|
||||
for _, aid := range opts.ArtistIDs {
|
||||
if aid == 0 {
|
||||
return nil, errors.New("none of 'ArtistIDs' may be 0")
|
||||
return nil, errors.New("SaveTrack: none of 'ArtistIDs' may be 0")
|
||||
}
|
||||
}
|
||||
if opts.AlbumID == 0 {
|
||||
return nil, errors.New("required parameter 'AlbumID' missing")
|
||||
return nil, errors.New("SaveTrack: required parameter 'AlbumID' missing")
|
||||
}
|
||||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveTrack: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
|
|
@ -120,7 +121,7 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
|
|||
ReleaseID: opts.AlbumID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveTrack: InsertTrack: %w", err)
|
||||
}
|
||||
// insert associated artists
|
||||
for _, aid := range opts.ArtistIDs {
|
||||
|
|
@ -129,7 +130,7 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
|
|||
TrackID: trackRow.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveTrack: AssociateArtistToTrack: %w", err)
|
||||
}
|
||||
}
|
||||
// insert primary alias
|
||||
|
|
@ -140,11 +141,11 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
|
|||
IsPrimary: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveTrack: InsertTrackAlias: %w", err)
|
||||
}
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveTrack: Commit: %w", err)
|
||||
}
|
||||
return &models.Track{
|
||||
ID: trackRow.ID,
|
||||
|
|
@ -156,12 +157,12 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
|
|||
func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error {
|
||||
l := logger.FromContext(ctx)
|
||||
if opts.ID == 0 {
|
||||
return errors.New("track id not specified")
|
||||
return errors.New("UpdateTrack: track id not specified")
|
||||
}
|
||||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return err
|
||||
return fmt.Errorf("UpdateTrack: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
|
|
@ -172,7 +173,7 @@ func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error {
|
|||
MusicBrainzID: &opts.MusicBrainzID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("UpdateTrack: UpdateTrackMbzID: %w", err)
|
||||
}
|
||||
}
|
||||
if opts.Duration != 0 {
|
||||
|
|
@ -182,7 +183,7 @@ func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error {
|
|||
Duration: opts.Duration,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("UpdateTrack: UpdateTrackDuration: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
|
|
@ -191,18 +192,18 @@ func (d *Psql) UpdateTrack(ctx context.Context, opts db.UpdateTrackOpts) error {
|
|||
func (d *Psql) SaveTrackAliases(ctx context.Context, id int32, aliases []string, source string) error {
|
||||
l := logger.FromContext(ctx)
|
||||
if id == 0 {
|
||||
return errors.New("track id not specified")
|
||||
return errors.New("SaveTrackAliases: track id not specified")
|
||||
}
|
||||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return err
|
||||
return fmt.Errorf("SaveTrackAliases: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
existing, err := qtx.GetAllTrackAliases(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SaveTrackAliases: GetAllTrackAliases: %w", err)
|
||||
}
|
||||
for _, v := range existing {
|
||||
aliases = append(aliases, v.Alias)
|
||||
|
|
@ -219,7 +220,7 @@ func (d *Psql) SaveTrackAliases(ctx context.Context, id int32, aliases []string,
|
|||
IsPrimary: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SaveTrackAliases: InsertTrackAlias: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
|
|
@ -239,7 +240,7 @@ func (d *Psql) DeleteTrackAlias(ctx context.Context, id int32, alias string) err
|
|||
func (d *Psql) GetAllTrackAliases(ctx context.Context, id int32) ([]models.Alias, error) {
|
||||
rows, err := d.q.GetAllTrackAliases(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetAllTrackAliases: GetAllTrackAliases: %w", err)
|
||||
}
|
||||
aliases := make([]models.Alias, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
@ -261,14 +262,14 @@ func (d *Psql) SetPrimaryTrackAlias(ctx context.Context, id int32, alias string)
|
|||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return err
|
||||
return fmt.Errorf("SetPrimaryTrackAlias: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
// get all aliases
|
||||
aliases, err := qtx.GetAllTrackAliases(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SetPrimaryTrackAlias: GetAllTrackAliases: %w", err)
|
||||
}
|
||||
primary := ""
|
||||
exists := false
|
||||
|
|
@ -293,7 +294,7 @@ func (d *Psql) SetPrimaryTrackAlias(ctx context.Context, id int32, alias string)
|
|||
IsPrimary: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SetPrimaryTrackAlias: SetTrackAliasPrimaryStatus: %w", err)
|
||||
}
|
||||
err = qtx.SetTrackAliasPrimaryStatus(ctx, repository.SetTrackAliasPrimaryStatusParams{
|
||||
TrackID: id,
|
||||
|
|
@ -301,7 +302,61 @@ func (d *Psql) SetPrimaryTrackAlias(ctx context.Context, id int32, alias string)
|
|||
IsPrimary: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("SetPrimaryTrackAlias: SetTrackAliasPrimaryStatus: %w", err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (d *Psql) SetPrimaryTrackArtist(ctx context.Context, id int32, artistId int32, value bool) error {
|
||||
l := logger.FromContext(ctx)
|
||||
if id == 0 {
|
||||
return errors.New("artist id not specified")
|
||||
}
|
||||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return fmt.Errorf("SetPrimaryTrackArtist: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
// get all artists
|
||||
artists, err := qtx.GetTrackArtists(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SetPrimaryTrackArtist: GetTrackArtists: %w", err)
|
||||
}
|
||||
var primary int32
|
||||
for _, v := range artists {
|
||||
// i dont get it??? is_primary is not a nullable column??? why use pgtype.Bool???
|
||||
// why not just use boolean??? is sqlc stupid??? am i stupid???????
|
||||
if v.IsPrimary.Valid && v.IsPrimary.Bool {
|
||||
primary = v.ID
|
||||
}
|
||||
}
|
||||
if value && primary == artistId {
|
||||
// no-op
|
||||
return nil
|
||||
}
|
||||
l.Debug().Msgf("Marking artist with id %d as 'primary = %v' on track with id %d", artistId, value, id)
|
||||
err = qtx.UpdateTrackPrimaryArtist(ctx, repository.UpdateTrackPrimaryArtistParams{
|
||||
TrackID: id,
|
||||
ArtistID: artistId,
|
||||
IsPrimary: value,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("SetPrimaryTrackArtist: UpdateTrackPrimaryArtist: %w", err)
|
||||
}
|
||||
if value && primary != 0 {
|
||||
l.Debug().Msgf("Unmarking artist with id %d as primary on track with id %d", primary, id)
|
||||
// if we were marking a new one as primary and there was already one marked as primary,
|
||||
// unmark that one as there can only be one
|
||||
err = qtx.UpdateTrackPrimaryArtist(ctx, repository.UpdateTrackPrimaryArtistParams{
|
||||
TrackID: id,
|
||||
ArtistID: primary,
|
||||
IsPrimary: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("SetPrimaryTrackArtist: UpdateTrackPrimaryArtist: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package psql
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
|
@ -21,7 +22,7 @@ func (d *Psql) GetUserByUsername(ctx context.Context, username string) (*models.
|
|||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetUserByUsername: %w", err)
|
||||
}
|
||||
return &models.User{
|
||||
ID: row.ID,
|
||||
|
|
@ -37,7 +38,7 @@ func (d *Psql) GetUserByApiKey(ctx context.Context, key string) (*models.User, e
|
|||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetUserByApiKey: %w", err)
|
||||
}
|
||||
return &models.User{
|
||||
ID: row.ID,
|
||||
|
|
@ -52,12 +53,12 @@ func (d *Psql) SaveUser(ctx context.Context, opts db.SaveUserOpts) (*models.User
|
|||
err := ValidateUsername(opts.Username)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("validator_notice", err).Msgf("Username failed validation: %s", opts.Username)
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveUser: ValidateUsername: %w", err)
|
||||
}
|
||||
pw, err := ValidateAndNormalizePassword(opts.Password)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("validator_notice", err).Msgf("Password failed validation")
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveUser: ValidateAndNormalizePassword: %w", err)
|
||||
}
|
||||
if opts.Role == "" {
|
||||
opts.Role = models.UserRoleUser
|
||||
|
|
@ -65,7 +66,7 @@ func (d *Psql) SaveUser(ctx context.Context, opts db.SaveUserOpts) (*models.User
|
|||
hashPw, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to generate hashed password")
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveUser: bcrypt.GenerateFromPassword: %w", err)
|
||||
}
|
||||
u, err := d.q.InsertUser(ctx, repository.InsertUserParams{
|
||||
Username: strings.ToLower(opts.Username),
|
||||
|
|
@ -73,7 +74,7 @@ func (d *Psql) SaveUser(ctx context.Context, opts db.SaveUserOpts) (*models.User
|
|||
Role: repository.Role(opts.Role),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveUser: InsertUser: %w", err)
|
||||
}
|
||||
return &models.User{
|
||||
ID: u.ID,
|
||||
|
|
@ -88,7 +89,7 @@ func (d *Psql) SaveApiKey(ctx context.Context, opts db.SaveApiKeyOpts) (*models.
|
|||
UserID: opts.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SaveApiKey: InsertApiKey: %w", err)
|
||||
}
|
||||
return &models.ApiKey{
|
||||
ID: row.ID,
|
||||
|
|
@ -107,7 +108,7 @@ func (d *Psql) UpdateUser(ctx context.Context, opts db.UpdateUserOpts) error {
|
|||
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to begin transaction")
|
||||
return err
|
||||
return fmt.Errorf("UpdateUser: BeginTx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
|
|
@ -115,33 +116,33 @@ func (d *Psql) UpdateUser(ctx context.Context, opts db.UpdateUserOpts) error {
|
|||
err := ValidateUsername(opts.Username)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("validator_notice", err).Msgf("Username failed validation: %s", opts.Username)
|
||||
return err
|
||||
return fmt.Errorf("UpdateUser: ValidateUsername: %w", err)
|
||||
}
|
||||
err = qtx.UpdateUserUsername(ctx, repository.UpdateUserUsernameParams{
|
||||
ID: opts.ID,
|
||||
Username: opts.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("UpdateUser: UpdateUserUsername: %w", err)
|
||||
}
|
||||
}
|
||||
if opts.Password != "" {
|
||||
pw, err := ValidateAndNormalizePassword(opts.Password)
|
||||
if err != nil {
|
||||
l.Debug().AnErr("validator_notice", err).Msgf("Password failed validation")
|
||||
return err
|
||||
return fmt.Errorf("UpdateUser: ValidateAndNormalizePassword: %w", err)
|
||||
}
|
||||
hashPw, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to generate hashed password")
|
||||
return err
|
||||
return fmt.Errorf("UpdateUser: bcrypt.GenerateFromPassword: %w", err)
|
||||
}
|
||||
err = qtx.UpdateUserPassword(ctx, repository.UpdateUserPasswordParams{
|
||||
ID: opts.ID,
|
||||
Password: hashPw,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("UpdateUser: UpdateUserPassword: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
|
|
@ -150,7 +151,7 @@ func (d *Psql) UpdateUser(ctx context.Context, opts db.UpdateUserOpts) error {
|
|||
func (d *Psql) GetApiKeysByUserID(ctx context.Context, id int32) ([]models.ApiKey, error) {
|
||||
rows, err := d.q.GetAllApiKeysByUserID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetApiKeysByUserID: %w", err)
|
||||
}
|
||||
keys := make([]models.ApiKey, len(rows))
|
||||
for i, row := range rows {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ func NewDeezerClient() *DeezerClient {
|
|||
ret := new(DeezerClient)
|
||||
ret.url = deezerBaseUrl
|
||||
ret.userAgent = cfg.UserAgent()
|
||||
ret.requestQueue = queue.NewRequestQueue(1, 1)
|
||||
ret.requestQueue = queue.NewRequestQueue(5, 5)
|
||||
return ret
|
||||
}
|
||||
|
||||
|
|
@ -92,19 +92,19 @@ func (c *DeezerClient) getEntity(ctx context.Context, endpoint string, result an
|
|||
l.Debug().Msgf("Sending request to ImageSrc: GET %s", url)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("getEntity: %w", err)
|
||||
}
|
||||
l.Debug().Msg("Adding ImageSrc request to queue")
|
||||
body, err := c.queue(ctx, req)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Deezer request failed")
|
||||
return err
|
||||
return fmt.Errorf("getEntity: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, result)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to unmarshal Deezer response")
|
||||
return err
|
||||
return fmt.Errorf("getEntity: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -121,10 +121,10 @@ func (c *DeezerClient) GetArtistImages(ctx context.Context, aliases []string) (s
|
|||
for _, a := range aliasesAscii {
|
||||
err := c.getEntity(ctx, fmt.Sprintf(artistImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"", a))), resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("GetArtistImages: %w", err)
|
||||
}
|
||||
if len(resp.Data) < 1 {
|
||||
return "", errors.New("artist image not found")
|
||||
return "", errors.New("GetArtistImages: artist image not found")
|
||||
}
|
||||
for _, v := range resp.Data {
|
||||
if strings.EqualFold(v.Name, a) {
|
||||
|
|
@ -139,10 +139,10 @@ func (c *DeezerClient) GetArtistImages(ctx context.Context, aliases []string) (s
|
|||
for _, a := range utils.RemoveInBoth(aliasesUniq, aliasesAscii) {
|
||||
err := c.getEntity(ctx, fmt.Sprintf(artistImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"", a))), resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("GetArtistImages: %w", err)
|
||||
}
|
||||
if len(resp.Data) < 1 {
|
||||
return "", errors.New("artist image not found")
|
||||
return "", errors.New("GetArtistImages: artist image not found")
|
||||
}
|
||||
for _, v := range resp.Data {
|
||||
if strings.EqualFold(v.Name, a) {
|
||||
|
|
@ -152,7 +152,7 @@ func (c *DeezerClient) GetArtistImages(ctx context.Context, aliases []string) (s
|
|||
}
|
||||
}
|
||||
}
|
||||
return "", errors.New("artist image not found")
|
||||
return "", errors.New("GetArtistImages: artist image not found")
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, album string) (string, error) {
|
||||
|
|
@ -163,7 +163,7 @@ func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, alb
|
|||
for _, alias := range artists {
|
||||
err := c.getEntity(ctx, fmt.Sprintf(albumImageEndpoint, url.QueryEscape(fmt.Sprintf("artist:\"%s\"album:\"%s\"", alias, album))), resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("GetAlbumImages: %w", err)
|
||||
}
|
||||
if len(resp.Data) > 0 {
|
||||
for _, v := range resp.Data {
|
||||
|
|
@ -179,7 +179,7 @@ func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, alb
|
|||
// if none are found, try to find an album just by album title
|
||||
err := c.getEntity(ctx, fmt.Sprintf(albumImageEndpoint, url.QueryEscape(fmt.Sprintf("album:\"%s\"", album))), resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("GetAlbumImages: %w", err)
|
||||
}
|
||||
for _, v := range resp.Data {
|
||||
if strings.EqualFold(v.Title, album) {
|
||||
|
|
@ -189,5 +189,5 @@ func (c *DeezerClient) GetAlbumImages(ctx context.Context, artists []string, alb
|
|||
}
|
||||
}
|
||||
|
||||
return "", errors.New("album image not found")
|
||||
return "", errors.New("GetAlbumImages: album image not found")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
|
|||
}
|
||||
return img, nil
|
||||
}
|
||||
l.Warn().Msg("No image providers are enabled")
|
||||
l.Warn().Msg("GetArtistImage: No image providers are enabled")
|
||||
return "", nil
|
||||
}
|
||||
func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) {
|
||||
|
|
@ -102,6 +102,6 @@ func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) {
|
|||
}
|
||||
return img, nil
|
||||
}
|
||||
l.Warn().Msg("No image providers are enabled")
|
||||
l.Warn().Msg("GetAlbumImage: No image providers are enabled")
|
||||
return "", nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package importer
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
|
|
@ -46,7 +47,7 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall
|
|||
file, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename))
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Failed to read import file: %s", filename)
|
||||
return err
|
||||
return fmt.Errorf("ImportLastFMFile: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
var throttleFunc = func() {}
|
||||
|
|
@ -58,7 +59,7 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall
|
|||
export := make([]LastFMExportPage, 0)
|
||||
err = json.NewDecoder(file).Decode(&export)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("ImportLastFMFile: %w", err)
|
||||
}
|
||||
count := 0
|
||||
for _, item := range export {
|
||||
|
|
@ -88,7 +89,8 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall
|
|||
if err != nil {
|
||||
ts, err = time.Parse("02 Jan 2006, 15:04", track.Date.Text)
|
||||
if err != nil {
|
||||
ts = time.Now().UTC()
|
||||
l.Err(err).Msg("Could not parse time from listen activity, skipping...")
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
ts = time.Unix(unix, 0).UTC()
|
||||
|
|
@ -116,11 +118,12 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall
|
|||
Client: "lastfm",
|
||||
Time: ts,
|
||||
UserID: 1,
|
||||
SkipCacheImage: !cfg.FetchImagesDuringImport(),
|
||||
}
|
||||
err = catalog.SubmitListen(ctx, store, opts)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to import LastFM playback item")
|
||||
return err
|
||||
return fmt.Errorf("ImportLastFMFile: %w", err)
|
||||
}
|
||||
count++
|
||||
throttleFunc()
|
||||
|
|
|
|||
|
|
@ -141,11 +141,12 @@ func ImportListenBrainzFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrai
|
|||
Time: ts,
|
||||
UserID: 1,
|
||||
Client: client,
|
||||
SkipCacheImage: !cfg.FetchImagesDuringImport(),
|
||||
}
|
||||
err = catalog.SubmitListen(ctx, store, opts)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to import LastFM playback item")
|
||||
return err
|
||||
return fmt.Errorf("ImportListenBrainzFile: %w", err)
|
||||
}
|
||||
count++
|
||||
throttleFunc()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package importer
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
|
@ -37,7 +38,7 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error {
|
|||
file, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename))
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Failed to read import file: %s", filename)
|
||||
return err
|
||||
return fmt.Errorf("ImportMalojaFile: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
var throttleFunc = func() {}
|
||||
|
|
@ -49,7 +50,7 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error {
|
|||
export := new(MalojaExport)
|
||||
err = json.NewDecoder(file).Decode(&export)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("ImportMalojaFile: %w", err)
|
||||
}
|
||||
for _, item := range export.Scrobbles {
|
||||
martists := make([]string, 0)
|
||||
|
|
@ -71,19 +72,20 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error {
|
|||
continue
|
||||
}
|
||||
opts := catalog.SubmitListenOpts{
|
||||
MbzCaller: &mbz.MusicBrainzClient{},
|
||||
Artist: item.Track.Artists[0],
|
||||
ArtistNames: artists,
|
||||
TrackTitle: item.Track.Title,
|
||||
ReleaseTitle: item.Track.Album.Title,
|
||||
Time: ts.Local(),
|
||||
Client: "maloja",
|
||||
UserID: 1,
|
||||
MbzCaller: &mbz.MusicBrainzClient{},
|
||||
Artist: item.Track.Artists[0],
|
||||
ArtistNames: artists,
|
||||
TrackTitle: item.Track.Title,
|
||||
ReleaseTitle: item.Track.Album.Title,
|
||||
Time: ts.Local(),
|
||||
Client: "maloja",
|
||||
UserID: 1,
|
||||
SkipCacheImage: !cfg.FetchImagesDuringImport(),
|
||||
}
|
||||
err = catalog.SubmitListen(ctx, store, opts)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to import maloja playback item")
|
||||
return err
|
||||
return fmt.Errorf("ImportMalojaFile: %w", err)
|
||||
}
|
||||
throttleFunc()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package importer
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
|
@ -29,7 +30,7 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error
|
|||
file, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename))
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Failed to read import file: %s", filename)
|
||||
return err
|
||||
return fmt.Errorf("ImportSpotifyFile: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
var throttleFunc = func() {}
|
||||
|
|
@ -41,7 +42,7 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error
|
|||
export := make([]SpotifyExportItem, 0)
|
||||
err = json.NewDecoder(file).Decode(&export)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("ImportSpotifyFile: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range export {
|
||||
|
|
@ -58,19 +59,20 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error
|
|||
continue
|
||||
}
|
||||
opts := catalog.SubmitListenOpts{
|
||||
MbzCaller: &mbz.MusicBrainzClient{},
|
||||
Artist: item.ArtistName,
|
||||
TrackTitle: item.TrackName,
|
||||
ReleaseTitle: item.AlbumName,
|
||||
Duration: dur / 1000,
|
||||
Time: item.Timestamp,
|
||||
Client: "spotify",
|
||||
UserID: 1,
|
||||
MbzCaller: &mbz.MusicBrainzClient{},
|
||||
Artist: item.ArtistName,
|
||||
TrackTitle: item.TrackName,
|
||||
ReleaseTitle: item.AlbumName,
|
||||
Duration: dur / 1000,
|
||||
Time: item.Timestamp,
|
||||
Client: "spotify",
|
||||
UserID: 1,
|
||||
SkipCacheImage: !cfg.FetchImagesDuringImport(),
|
||||
}
|
||||
err = catalog.SubmitListen(ctx, store, opts)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to import spotify playback item")
|
||||
return err
|
||||
return fmt.Errorf("ImportSpotifyFile: %w", err)
|
||||
}
|
||||
throttleFunc()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package mbz
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/gabehf/koito/internal/logger"
|
||||
|
|
@ -28,7 +29,7 @@ func (c *MusicBrainzClient) getArtist(ctx context.Context, id uuid.UUID) (*Music
|
|||
mbzArtist := new(MusicBrainzArtist)
|
||||
err := c.getEntity(ctx, artistAliasFmtStr, id, mbzArtist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("getArtist: %w", err)
|
||||
}
|
||||
return mbzArtist, nil
|
||||
}
|
||||
|
|
@ -38,10 +39,10 @@ func (c *MusicBrainzClient) GetArtistPrimaryAliases(ctx context.Context, id uuid
|
|||
l := logger.FromContext(ctx)
|
||||
artist, err := c.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetArtistPrimaryAliases: %w", err)
|
||||
}
|
||||
if artist == nil {
|
||||
return nil, errors.New("artist could not be found by musicbrainz")
|
||||
return nil, errors.New("GetArtistPrimaryAliases: artist could not be found by musicbrainz")
|
||||
}
|
||||
used := make(map[string]bool)
|
||||
ret := make([]string, 1)
|
||||
|
|
|
|||
|
|
@ -52,19 +52,19 @@ func (c *MusicBrainzClient) getEntity(ctx context.Context, fmtStr string, id uui
|
|||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("Failed to build MusicBrainz request")
|
||||
return err
|
||||
return fmt.Errorf("getEntity: %w", err)
|
||||
}
|
||||
l.Debug().Msg("Adding MusicBrainz request to queue")
|
||||
body, err := c.queue(ctx, req)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("MusicBrainz request failed")
|
||||
return err
|
||||
return fmt.Errorf("getEntity: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, result)
|
||||
if err != nil {
|
||||
l.Err(err).Str("body", string(body)).Msg("Failed to unmarshal MusicBrainz response body")
|
||||
return err
|
||||
return fmt.Errorf("getEntity: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package mbz
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -36,7 +37,7 @@ func (c *MusicBrainzClient) GetReleaseGroup(ctx context.Context, id uuid.UUID) (
|
|||
mbzRG := new(MusicBrainzReleaseGroup)
|
||||
err := c.getEntity(ctx, releaseGroupFmtStr, id, mbzRG)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetReleaseGroup: %w", err)
|
||||
}
|
||||
return mbzRG, nil
|
||||
}
|
||||
|
|
@ -45,7 +46,7 @@ func (c *MusicBrainzClient) GetRelease(ctx context.Context, id uuid.UUID) (*Musi
|
|||
mbzRelease := new(MusicBrainzRelease)
|
||||
err := c.getEntity(ctx, releaseFmtStr, id, mbzRelease)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetRelease: %w", err)
|
||||
}
|
||||
return mbzRelease, nil
|
||||
}
|
||||
|
|
@ -53,7 +54,7 @@ func (c *MusicBrainzClient) GetRelease(ctx context.Context, id uuid.UUID) (*Musi
|
|||
func (c *MusicBrainzClient) GetReleaseTitles(ctx context.Context, RGID uuid.UUID) ([]string, error) {
|
||||
releaseGroup, err := c.GetReleaseGroup(ctx, RGID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetReleaseTitles: %w", err)
|
||||
}
|
||||
|
||||
var titles []string
|
||||
|
|
@ -80,7 +81,7 @@ func ReleaseGroupToTitles(rg *MusicBrainzReleaseGroup) []string {
|
|||
func (c *MusicBrainzClient) GetLatinTitles(ctx context.Context, id uuid.UUID) ([]string, error) {
|
||||
rg, err := c.GetReleaseGroup(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetLatinTitles: %w", err)
|
||||
}
|
||||
titles := make([]string, 0)
|
||||
for _, r := range rg.Releases {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package mbz
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
|
@ -17,7 +18,7 @@ func (c *MusicBrainzClient) GetTrack(ctx context.Context, id uuid.UUID) (*MusicB
|
|||
track := new(MusicBrainzTrack)
|
||||
err := c.getEntity(ctx, recordingFmtStr, id, track)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("GetTrack: %w", err)
|
||||
}
|
||||
return track, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ type Artist struct {
|
|||
Image *uuid.UUID `json:"image"`
|
||||
ListenCount int64 `json:"listen_count"`
|
||||
TimeListened int64 `json:"time_listened"`
|
||||
IsPrimary bool `json:"is_primary,omitempty"`
|
||||
}
|
||||
|
||||
type SimpleArtist struct {
|
||||
|
|
|
|||
|
|
@ -199,28 +199,39 @@ func (q *Queries) GetArtistByName(ctx context.Context, alias string) (GetArtistB
|
|||
|
||||
const getReleaseArtists = `-- name: GetReleaseArtists :many
|
||||
SELECT
|
||||
a.id, a.musicbrainz_id, a.image, a.image_source, a.name
|
||||
a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
|
||||
ar.is_primary as is_primary
|
||||
FROM artists_with_name a
|
||||
LEFT JOIN artist_releases ar ON a.id = ar.artist_id
|
||||
WHERE ar.release_id = $1
|
||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name
|
||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, ar.is_primary
|
||||
`
|
||||
|
||||
func (q *Queries) GetReleaseArtists(ctx context.Context, releaseID int32) ([]ArtistsWithName, error) {
|
||||
type GetReleaseArtistsRow struct {
|
||||
ID int32
|
||||
MusicBrainzID *uuid.UUID
|
||||
Image *uuid.UUID
|
||||
ImageSource pgtype.Text
|
||||
Name string
|
||||
IsPrimary pgtype.Bool
|
||||
}
|
||||
|
||||
func (q *Queries) GetReleaseArtists(ctx context.Context, releaseID int32) ([]GetReleaseArtistsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getReleaseArtists, releaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ArtistsWithName
|
||||
var items []GetReleaseArtistsRow
|
||||
for rows.Next() {
|
||||
var i ArtistsWithName
|
||||
var i GetReleaseArtistsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.MusicBrainzID,
|
||||
&i.Image,
|
||||
&i.ImageSource,
|
||||
&i.Name,
|
||||
&i.IsPrimary,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -297,28 +308,39 @@ func (q *Queries) GetTopArtistsPaginated(ctx context.Context, arg GetTopArtistsP
|
|||
|
||||
const getTrackArtists = `-- name: GetTrackArtists :many
|
||||
SELECT
|
||||
a.id, a.musicbrainz_id, a.image, a.image_source, a.name
|
||||
a.id, a.musicbrainz_id, a.image, a.image_source, a.name,
|
||||
at.is_primary as is_primary
|
||||
FROM artists_with_name a
|
||||
LEFT JOIN artist_tracks at ON a.id = at.artist_id
|
||||
WHERE at.track_id = $1
|
||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name
|
||||
GROUP BY a.id, a.musicbrainz_id, a.image, a.image_source, a.name, at.is_primary
|
||||
`
|
||||
|
||||
func (q *Queries) GetTrackArtists(ctx context.Context, trackID int32) ([]ArtistsWithName, error) {
|
||||
type GetTrackArtistsRow struct {
|
||||
ID int32
|
||||
MusicBrainzID *uuid.UUID
|
||||
Image *uuid.UUID
|
||||
ImageSource pgtype.Text
|
||||
Name string
|
||||
IsPrimary pgtype.Bool
|
||||
}
|
||||
|
||||
func (q *Queries) GetTrackArtists(ctx context.Context, trackID int32) ([]GetTrackArtistsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getTrackArtists, trackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ArtistsWithName
|
||||
var items []GetTrackArtistsRow
|
||||
for rows.Next() {
|
||||
var i ArtistsWithName
|
||||
var i GetTrackArtistsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.MusicBrainzID,
|
||||
&i.Image,
|
||||
&i.ImageSource,
|
||||
&i.Name,
|
||||
&i.IsPrimary,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,12 +194,7 @@ SELECT
|
|||
l.track_id, l.listened_at, l.client, l.user_id,
|
||||
t.title AS track_title,
|
||||
t.release_id AS release_id,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
JOIN artist_tracks at ON t.id = at.track_id
|
||||
|
|
@ -266,12 +261,7 @@ SELECT
|
|||
l.track_id, l.listened_at, l.client, l.user_id,
|
||||
t.title AS track_title,
|
||||
t.release_id AS release_id,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
|
|
@ -337,12 +327,7 @@ SELECT
|
|||
l.track_id, l.listened_at, l.client, l.user_id,
|
||||
t.title AS track_title,
|
||||
t.release_id AS release_id,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
|
|
@ -408,12 +393,7 @@ SELECT
|
|||
l.track_id, l.listened_at, l.client, l.user_id,
|
||||
t.title AS track_title,
|
||||
t.release_id AS release_id,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
|
|
|
|||
|
|
@ -80,11 +80,13 @@ type ArtistAlias struct {
|
|||
type ArtistRelease struct {
|
||||
ArtistID int32
|
||||
ReleaseID int32
|
||||
IsPrimary bool
|
||||
}
|
||||
|
||||
type ArtistTrack struct {
|
||||
ArtistID int32
|
||||
TrackID int32
|
||||
ArtistID int32
|
||||
TrackID int32
|
||||
IsPrimary bool
|
||||
}
|
||||
|
||||
type ArtistsWithName struct {
|
||||
|
|
|
|||
|
|
@ -197,12 +197,7 @@ func (q *Queries) GetReleaseByMbzID(ctx context.Context, musicbrainzID *uuid.UUI
|
|||
const getReleasesWithoutImages = `-- name: GetReleasesWithoutImages :many
|
||||
SELECT
|
||||
r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON a.id = ar.artist_id
|
||||
WHERE ar.release_id = r.id
|
||||
) AS artists
|
||||
get_artists_for_release(r.id) AS artists
|
||||
FROM releases_with_title r
|
||||
WHERE r.image IS NULL
|
||||
AND r.id > $2
|
||||
|
|
@ -257,12 +252,7 @@ const getTopReleasesFromArtist = `-- name: GetTopReleasesFromArtist :many
|
|||
SELECT
|
||||
r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
||||
WHERE ar.release_id = r.id
|
||||
) AS artists
|
||||
get_artists_for_release(r.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN releases_with_title r ON t.release_id = r.id
|
||||
|
|
@ -332,12 +322,7 @@ const getTopReleasesPaginated = `-- name: GetTopReleasesPaginated :many
|
|||
SELECT
|
||||
r.id, r.musicbrainz_id, r.image, r.various_artists, r.image_source, r.title,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
||||
WHERE ar.release_id = r.id
|
||||
) AS artists
|
||||
get_artists_for_release(r.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN releases_with_title r ON t.release_id = r.id
|
||||
|
|
@ -461,6 +446,22 @@ func (q *Queries) UpdateReleaseMbzID(ctx context.Context, arg UpdateReleaseMbzID
|
|||
return err
|
||||
}
|
||||
|
||||
const updateReleasePrimaryArtist = `-- name: UpdateReleasePrimaryArtist :exec
|
||||
UPDATE artist_releases SET is_primary = $3
|
||||
WHERE artist_id = $1 AND release_id = $2
|
||||
`
|
||||
|
||||
type UpdateReleasePrimaryArtistParams struct {
|
||||
ArtistID int32
|
||||
ReleaseID int32
|
||||
IsPrimary bool
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateReleasePrimaryArtist(ctx context.Context, arg UpdateReleasePrimaryArtistParams) error {
|
||||
_, err := q.db.Exec(ctx, updateReleasePrimaryArtist, arg.ArtistID, arg.ReleaseID, arg.IsPrimary)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateReleaseVariousArtists = `-- name: UpdateReleaseVariousArtists :exec
|
||||
UPDATE releases SET various_artists = $2
|
||||
WHERE id = $1
|
||||
|
|
|
|||
|
|
@ -136,12 +136,7 @@ SELECT
|
|||
ranked.image,
|
||||
ranked.various_artists,
|
||||
ranked.score,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
||||
WHERE ar.release_id = ranked.id
|
||||
) AS artists
|
||||
get_artists_for_release(ranked.id) AS artists
|
||||
FROM (
|
||||
SELECT
|
||||
r.id,
|
||||
|
|
@ -211,12 +206,7 @@ SELECT
|
|||
ranked.image,
|
||||
ranked.various_artists,
|
||||
ranked.score,
|
||||
(
|
||||
SELECT json_agg(DISTINCT jsonb_build_object('id', a.id, 'name', a.name))
|
||||
FROM artists_with_name a
|
||||
JOIN artist_releases ar ON ar.artist_id = a.id
|
||||
WHERE ar.release_id = ranked.id
|
||||
) AS artists
|
||||
get_artists_for_release(ranked.id) AS artists
|
||||
FROM (
|
||||
SELECT
|
||||
r.id,
|
||||
|
|
@ -286,12 +276,7 @@ SELECT
|
|||
ranked.release_id,
|
||||
ranked.image,
|
||||
ranked.score,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = ranked.id
|
||||
) AS artists
|
||||
get_artists_for_track(ranked.id) AS artists
|
||||
FROM (
|
||||
SELECT
|
||||
t.id,
|
||||
|
|
@ -362,12 +347,7 @@ SELECT
|
|||
ranked.release_id,
|
||||
ranked.image,
|
||||
ranked.score,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = ranked.id
|
||||
) AS artists
|
||||
get_artists_for_track(ranked.id) AS artists
|
||||
FROM (
|
||||
SELECT
|
||||
t.id,
|
||||
|
|
|
|||
|
|
@ -138,12 +138,7 @@ SELECT
|
|||
t.release_id,
|
||||
r.image,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at2
|
||||
JOIN artists_with_name a ON a.id = at2.artist_id
|
||||
WHERE at2.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
JOIN releases r ON t.release_id = r.id
|
||||
|
|
@ -215,12 +210,7 @@ SELECT
|
|||
t.release_id,
|
||||
r.image,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at2
|
||||
JOIN artists_with_name a ON a.id = at2.artist_id
|
||||
WHERE at2.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
JOIN releases r ON t.release_id = r.id
|
||||
|
|
@ -291,12 +281,7 @@ SELECT
|
|||
t.release_id,
|
||||
r.image,
|
||||
COUNT(*) AS listen_count,
|
||||
(
|
||||
SELECT json_agg(json_build_object('id', a.id, 'name', a.name))
|
||||
FROM artist_tracks at
|
||||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
get_artists_for_track(t.id) AS artists
|
||||
FROM listens l
|
||||
JOIN tracks_with_title t ON l.track_id = t.id
|
||||
JOIN releases r ON t.release_id = r.id
|
||||
|
|
@ -502,3 +487,19 @@ func (q *Queries) UpdateTrackMbzID(ctx context.Context, arg UpdateTrackMbzIDPara
|
|||
_, err := q.db.Exec(ctx, updateTrackMbzID, arg.ID, arg.MusicBrainzID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateTrackPrimaryArtist = `-- name: UpdateTrackPrimaryArtist :exec
|
||||
UPDATE artist_tracks SET is_primary = $3
|
||||
WHERE artist_id = $1 AND track_id = $2
|
||||
`
|
||||
|
||||
type UpdateTrackPrimaryArtistParams struct {
|
||||
ArtistID int32
|
||||
TrackID int32
|
||||
IsPrimary bool
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateTrackPrimaryArtist(ctx context.Context, arg UpdateTrackPrimaryArtistParams) error {
|
||||
_, err := q.db.Exec(ctx, updateTrackPrimaryArtist, arg.ArtistID, arg.TrackID, arg.IsPrimary)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,22 +90,22 @@ func DateRange(week, month, year int) (time.Time, time.Time, error) {
|
|||
}
|
||||
|
||||
if month != 0 && (month < 1 || month > 12) {
|
||||
return time.Time{}, time.Time{}, errors.New("invalid month")
|
||||
return time.Time{}, time.Time{}, errors.New("DateRange: invalid month")
|
||||
}
|
||||
|
||||
if week != 0 && (week < 1 || week > 53) {
|
||||
return time.Time{}, time.Time{}, errors.New("invalid week")
|
||||
return time.Time{}, time.Time{}, errors.New("DateRange: invalid week")
|
||||
}
|
||||
|
||||
if year < 1 {
|
||||
return time.Time{}, time.Time{}, errors.New("invalid year")
|
||||
return time.Time{}, time.Time{}, errors.New("DateRange: invalid year")
|
||||
}
|
||||
|
||||
loc := time.Local
|
||||
|
||||
if week != 0 {
|
||||
if month != 0 {
|
||||
return time.Time{}, time.Time{}, errors.New("cannot specify both week and month")
|
||||
return time.Time{}, time.Time{}, errors.New("DateRange: cannot specify both week and month")
|
||||
}
|
||||
// Specific week
|
||||
start := time.Date(year, 1, 1, 0, 0, 0, 0, loc)
|
||||
|
|
@ -133,31 +133,34 @@ func DateRange(week, month, year int) (time.Time, time.Time, error) {
|
|||
func CopyFile(src, dst string) (err error) {
|
||||
sfi, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return
|
||||
return fmt.Errorf("CopyFile: %w", err)
|
||||
}
|
||||
if !sfi.Mode().IsRegular() {
|
||||
// cannot copy non-regular files (e.g., directories,
|
||||
// symlinks, devices, etc.)
|
||||
return fmt.Errorf("non-regular source file %s (%q)", sfi.Name(), sfi.Mode().String())
|
||||
return fmt.Errorf("CopyFile: non-regular source file %s (%q)", sfi.Name(), sfi.Mode().String())
|
||||
}
|
||||
dfi, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return
|
||||
return fmt.Errorf("CopyFile: %w", err)
|
||||
}
|
||||
} else {
|
||||
if !(dfi.Mode().IsRegular()) {
|
||||
return fmt.Errorf("non-regular destination file %s (%q)", dfi.Name(), dfi.Mode().String())
|
||||
return fmt.Errorf("CopyFile: non-regular destination file %s (%q)", dfi.Name(), dfi.Mode().String())
|
||||
}
|
||||
if os.SameFile(sfi, dfi) {
|
||||
return
|
||||
return fmt.Errorf("CopyFile: %w", err)
|
||||
}
|
||||
}
|
||||
if err = os.Link(src, dst); err == nil {
|
||||
return
|
||||
return fmt.Errorf("CopyFile: %w", err)
|
||||
}
|
||||
err = copyFileContents(src, dst)
|
||||
return
|
||||
if err != nil {
|
||||
return fmt.Errorf("CopyFile: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFileContents copies the contents of the file named src to the file named
|
||||
|
|
@ -167,24 +170,22 @@ func CopyFile(src, dst string) (err error) {
|
|||
func copyFileContents(src, dst string) (err error) {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return
|
||||
return fmt.Errorf("copyFileContents: %w", err)
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return
|
||||
return fmt.Errorf("copyFileContents: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
cerr := out.Close()
|
||||
if err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}()
|
||||
defer out.Close()
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
return
|
||||
return fmt.Errorf("copyFileContents: %w", err)
|
||||
}
|
||||
err = out.Sync()
|
||||
return
|
||||
if err != nil {
|
||||
return fmt.Errorf("copyFileContents: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns the same slice, but with all strings that are equal (with strings.EqualFold)
|
||||
|
|
@ -281,7 +282,7 @@ func GenerateRandomString(length int) (string, error) {
|
|||
for i := range length {
|
||||
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("GenerateRandomString: %w", err)
|
||||
}
|
||||
ret[i] = letters[num.Int64()]
|
||||
}
|
||||
|
|
@ -311,3 +312,18 @@ func MoreThanOneString(s ...string) bool {
|
|||
}
|
||||
return count > 1
|
||||
}
|
||||
|
||||
func ParseBool(s string) (value, ok bool) {
|
||||
if strings.ToLower(s) == "true" {
|
||||
value = true
|
||||
ok = true
|
||||
return
|
||||
} else if strings.ToLower(s) == "false" {
|
||||
value = false
|
||||
ok = true
|
||||
return
|
||||
} else {
|
||||
ok = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue