feat: v0.0.5

pull/20/head
Gabe Farrell 6 months ago
parent 4c4ebc593d
commit 242a82ad8c

@ -1,3 +1,14 @@
# v0.0.4 # v0.0.5
## Features
- Artist MusicBrainz IDs will now be mapped during ListenBrainz and LastFM imports, even when MusicBrainz is disabled
- Merges now support replacing images for artists and albums
- Time listened per item is now displayed on the item page, below the total play count
## Enhancements ## Enhancements
- Re-download images missing from cache on request - More reliable artist MusicBrainz ID mapping when scrobbling
## Fixes
- Token validation now correctly validates case-insensitive authorization scheme
## Docs
- Removed the portion that mentions not being able to map MusicBrainz IDs when it is disabled, as that is no longer true

@ -74,13 +74,13 @@ function mergeTracks(from: number, to: number): Promise<Response> {
method: "POST", method: "POST",
}) })
} }
function mergeAlbums(from: number, to: number): Promise<Response> { function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise<Response> {
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}`, { return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, {
method: "POST", method: "POST",
}) })
} }
function mergeArtists(from: number, to: number): Promise<Response> { function mergeArtists(from: number, to: number, replaceImage: boolean): Promise<Response> {
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}`, { return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, {
method: "POST", method: "POST",
}) })
} }
@ -200,6 +200,7 @@ type Track = {
image: string image: string
album_id: number album_id: number
musicbrainz_id: string musicbrainz_id: string
time_listened: number
} }
type Artist = { type Artist = {
id: number id: number
@ -208,6 +209,7 @@ type Artist = {
aliases: string[] aliases: string[]
listen_count: number listen_count: number
musicbrainz_id: string musicbrainz_id: string
time_listened: number
} }
type Album = { type Album = {
id: number, id: number,
@ -217,6 +219,7 @@ type Album = {
is_various_artists: boolean is_various_artists: boolean
artists: SimpleArtists[] artists: SimpleArtists[]
musicbrainz_id: string musicbrainz_id: string
time_listened: number
} }
type Alias = { type Alias = {
id: number id: number

@ -82,7 +82,7 @@ function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artis
<span className="color-fg-secondary">Various Artists</span> <span className="color-fg-secondary">Various Artists</span>
: :
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}> <div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
<ArtistLinks artists={album.artists || [{id: 0, Name: 'Unknown Artist'}]}/> <ArtistLinks artists={album.artists ? [album.artists[0]] : [{id: 0, name: 'Unknown Artist'}]}/>
</div> </div>
} }
<div className="color-fg-secondary">{album.listen_count} plays</div> <div className="color-fg-secondary">{album.listen_count} plays</div>

@ -21,6 +21,7 @@ export default function MergeModal(props: Props) {
const [debouncedQuery, setDebouncedQuery] = useState(query); const [debouncedQuery, setDebouncedQuery] = useState(query);
const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0}) const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0})
const [mergeOrderReversed, setMergeOrderReversed] = useState(false) const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
const [replaceImage, setReplaceImage] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
@ -53,7 +54,7 @@ export default function MergeModal(props: Props) {
from = {id: props.currentId, title: props.currentTitle} from = {id: props.currentId, title: props.currentTitle}
to = mergeTarget to = mergeTarget
} }
props.mergeFunc(from.id, to.id) props.mergeFunc(from.id, to.id, replaceImage)
.then(r => { .then(r => {
if (r.ok) { if (r.ok) {
if (mergeOrderReversed) { if (mergeOrderReversed) {
@ -117,6 +118,13 @@ export default function MergeModal(props: Props) {
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} /> <input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} />
<label htmlFor="reverse-merge-order">Reverse merge order</label> <label htmlFor="reverse-merge-order">Reverse merge order</label>
</div> </div>
{
(props.type.toLowerCase() === "album" || props.type.toLowerCase() === "artist") &&
<div className="flex gap-2 mt-3">
<input type="checkbox" name="replace-image" checked={replaceImage} onChange={() => setReplaceImage(!replaceImage)} />
<label htmlFor="replace-image">Replace image</label>
</div>
}
</> : </> :
''} ''}
</div> </div>

@ -6,6 +6,7 @@ import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector"; import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout"; import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid"; import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils";
export async function clientLoader({ params }: LoaderFunctionArgs) { export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/album?id=${params.id}`); const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
@ -40,9 +41,10 @@ export default function Album() {
} }
return r return r
}} }}
subContent={<> subContent={<div className="flex flex-col gap-2 items-start">
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>} {album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
</>} {<p>{timeListenedString(album.time_listened)}</p>}
</div>}
> >
<div className="mt-10"> <div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} /> <PeriodSelector setter={setPeriod} current={period} />

@ -7,6 +7,7 @@ import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout"; import MediaLayout from "./MediaLayout";
import ArtistAlbums from "~/components/ArtistAlbums"; import ArtistAlbums from "~/components/ArtistAlbums";
import ActivityGrid from "~/components/ActivityGrid"; import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils";
export async function clientLoader({ params }: LoaderFunctionArgs) { export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`); const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
@ -46,9 +47,10 @@ export default function Artist() {
} }
return r return r
}} }}
subContent={<> subContent={<div className="flex flex-col gap-2 items-start">
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>} {artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
</>} {<p>{timeListenedString(artist.time_listened)}</p>}
</div>}
> >
<div className="mt-10"> <div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} /> <PeriodSelector setter={setPeriod} current={period} />

@ -9,7 +9,7 @@ import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
import DeleteModal from "~/components/modals/DeleteModal"; import DeleteModal from "~/components/modals/DeleteModal";
import RenameModal from "~/components/modals/RenameModal"; import RenameModal from "~/components/modals/RenameModal";
export type MergeFunc = (from: number, to: number) => Promise<Response> export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise<Response>
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
interface Props { interface Props {

@ -5,6 +5,7 @@ import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector"; import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout"; import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid"; import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils";
export async function clientLoader({ params }: LoaderFunctionArgs) { export async function clientLoader({ params }: LoaderFunctionArgs) {
let res = await fetch(`/apis/web/v1/track?id=${params.id}`); let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
@ -42,9 +43,10 @@ export default function Track() {
} }
return r return r
}} }}
subContent={<div className="flex flex-col gap-4 items-start"> subContent={<div className="flex flex-col gap-2 items-start">
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link> <Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>} {track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
{<p>{timeListenedString(track.time_listened)}</p>}
</div>} </div>}
> >
<div className="mt-10"> <div className="mt-10">

@ -86,5 +86,17 @@ const hexToHSL = (hex: string): hsl => {
}; };
}; };
export {hexToHSL} const timeListenedString = (seconds: number) => {
if (!seconds) return ""
if (seconds > (120 * 60) - 1) {
let hours = Math.floor(seconds / 60 / 60)
return `${hours} hours listened`
} else {
let minutes = Math.floor(seconds / 60)
return `${minutes} minutes listened`
}
}
export {hexToHSL, timeListenedString}
export type {hsl} export type {hsl}

@ -12,8 +12,7 @@ Koito currently supports the following sources to import data from:
:::note :::note
ListenBrainz and LastFM imports can take a long time for large imports due to MusicBrainz requests being throttled at one per second. If you want ListenBrainz and LastFM imports can take a long time for large imports due to MusicBrainz requests being throttled at one per second. If you want
these imports to go faster, you can [disable MusicBrainz](/reference/configuration/#koito_disable_musicbrainz) in the config while running the importer. However, this these imports to go faster, you can [disable MusicBrainz](/reference/configuration/#koito_disable_musicbrainz) in the config while running the importer. However, this
means that artist aliases will not be automatically fetched for imported artists. This also means that artists will not be associated with their MusicBrainz IDs internally, means that artist aliases will not be automatically fetched for imported artists. You can also use
which can lead to some artist matching issues, especially for people who listen to lots of foreign music. You can also use
[your own MusicBrainz mirror](https://musicbrainz.org/doc/MusicBrainz_Server/Setup) and [your own MusicBrainz mirror](https://musicbrainz.org/doc/MusicBrainz_Server/Setup) and
[disable MusicBrainz rate limiting](/reference/configuration/#koito_musicbrainz_url) in the config if you want imports to be faster. [disable MusicBrainz rate limiting](/reference/configuration/#koito_musicbrainz_url) in the config if you want imports to be faster.
::: :::

@ -42,8 +42,19 @@ type LbzTrackMeta struct {
ArtistName string `json:"artist_name"` // required ArtistName string `json:"artist_name"` // required
TrackName string `json:"track_name"` // required TrackName string `json:"track_name"` // required
ReleaseName string `json:"release_name,omitempty"` ReleaseName string `json:"release_name,omitempty"`
MBIDMapping LbzMBIDMapping `json:"mbid_mapping"`
AdditionalInfo LbzAdditionalInfo `json:"additional_info,omitempty"` AdditionalInfo LbzAdditionalInfo `json:"additional_info,omitempty"`
} }
type LbzArtist struct {
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_credit_name"`
}
type LbzMBIDMapping struct {
ReleaseMBID string `json:"release_mbid"`
RecordingMBID string `json:"recording_mbid"`
ArtistMBIDs []string `json:"artist_mbids"`
Artists []LbzArtist `json:"artists"`
}
type LbzAdditionalInfo struct { type LbzAdditionalInfo struct {
MediaPlayer string `json:"media_player,omitempty"` MediaPlayer string `json:"media_player,omitempty"`
@ -128,17 +139,30 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
if err != nil { if err != nil {
l.Debug().Err(err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs") l.Debug().Err(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")
utils.ParseUUIDSlice(payload.TrackMeta.MBIDMapping.ArtistMBIDs)
if err != nil {
l.Debug().Err(err).Msg("LbzSubmitListenHandler: Failed to parse one or more UUIDs")
}
}
rgMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseGroupMBID) rgMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseGroupMBID)
if err != nil { if err != nil {
rgMbzID = uuid.Nil rgMbzID = uuid.Nil
} }
releaseMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseMBID) releaseMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseMBID)
if err != nil { if err != nil {
releaseMbzID = uuid.Nil releaseMbzID, err = uuid.Parse(payload.TrackMeta.MBIDMapping.ReleaseMBID)
if err != nil {
releaseMbzID = uuid.Nil
}
} }
recordingMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.RecordingMBID) recordingMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.RecordingMBID)
if err != nil { if err != nil {
recordingMbzID = uuid.Nil recordingMbzID, err = uuid.Parse(payload.TrackMeta.MBIDMapping.RecordingMBID)
if err != nil {
recordingMbzID = uuid.Nil
}
} }
var client string var client string
@ -160,20 +184,33 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
listenedAt = time.Unix(payload.ListenedAt, 0) listenedAt = time.Unix(payload.ListenedAt, 0)
} }
var artistMbidMap []catalog.ArtistMbidMap
for _, a := range payload.TrackMeta.MBIDMapping.Artists {
if a.ArtistMBID == "" || a.ArtistName == "" {
continue
}
mbid, err := uuid.Parse(a.ArtistMBID)
if err != nil {
l.Err(err).Msgf("LbzSubmitListenHandler: Failed to parse UUID for artist '%s'", a.ArtistName)
}
artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: a.ArtistName, Mbid: mbid})
}
opts := catalog.SubmitListenOpts{ opts := catalog.SubmitListenOpts{
MbzCaller: mbzc, MbzCaller: mbzc,
ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames, ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames,
Artist: payload.TrackMeta.ArtistName, Artist: payload.TrackMeta.ArtistName,
ArtistMbzIDs: artistMbzIDs, ArtistMbzIDs: artistMbzIDs,
TrackTitle: payload.TrackMeta.TrackName, TrackTitle: payload.TrackMeta.TrackName,
RecordingMbzID: recordingMbzID, RecordingMbzID: recordingMbzID,
ReleaseTitle: payload.TrackMeta.ReleaseName, ReleaseTitle: payload.TrackMeta.ReleaseName,
ReleaseMbzID: releaseMbzID, ReleaseMbzID: releaseMbzID,
ReleaseGroupMbzID: rgMbzID, ReleaseGroupMbzID: rgMbzID,
Duration: duration, ArtistMbidMappings: artistMbidMap,
Time: listenedAt, Duration: duration,
UserID: u.ID, Time: listenedAt,
Client: client, UserID: u.ID,
Client: client,
} }
if req.ListenType == ListenTypePlayingNow { if req.ListenType == ListenTypePlayingNow {

@ -3,6 +3,7 @@ package handlers
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/logger"
@ -67,9 +68,16 @@ func MergeReleaseGroupsHandler(store db.DB) http.HandlerFunc {
return return
} }
var replaceImage bool
replaceImgStr := r.URL.Query().Get("replace_image")
if strings.ToLower(replaceImgStr) == "true" {
l.Debug().Msg("MergeReleaseGroupsHandler: Merge will replace image")
replaceImage = true
}
l.Debug().Msgf("MergeReleaseGroupsHandler: Merging release groups from ID %d to ID %d", fromId, toId) l.Debug().Msgf("MergeReleaseGroupsHandler: Merging release groups from ID %d to ID %d", fromId, toId)
err = store.MergeAlbums(r.Context(), int32(fromId), int32(toId)) err = store.MergeAlbums(r.Context(), int32(fromId), int32(toId), replaceImage)
if err != nil { if err != nil {
l.Err(err).Msg("MergeReleaseGroupsHandler: Failed to merge release groups") l.Err(err).Msg("MergeReleaseGroupsHandler: Failed to merge release groups")
utils.WriteError(w, "Failed to merge release groups: "+err.Error(), http.StatusInternalServerError) utils.WriteError(w, "Failed to merge release groups: "+err.Error(), http.StatusInternalServerError)
@ -103,9 +111,16 @@ func MergeArtistsHandler(store db.DB) http.HandlerFunc {
return return
} }
var replaceImage bool
replaceImgStr := r.URL.Query().Get("replace_image")
if strings.ToLower(replaceImgStr) == "true" {
l.Debug().Msg("MergeReleaseGroupsHandler: Merge will replace image")
replaceImage = true
}
l.Debug().Msgf("MergeArtistsHandler: Merging artists from ID %d to ID %d", fromId, toId) l.Debug().Msgf("MergeArtistsHandler: Merging artists from ID %d to ID %d", fromId, toId)
err = store.MergeArtists(r.Context(), int32(fromId), int32(toId)) err = store.MergeArtists(r.Context(), int32(fromId), int32(toId), replaceImage)
if err != nil { if err != nil {
l.Err(err).Msg("MergeArtistsHandler: Failed to merge artists") l.Err(err).Msg("MergeArtistsHandler: Failed to merge artists")
utils.WriteError(w, "Failed to merge artists: "+err.Error(), http.StatusInternalServerError) utils.WriteError(w, "Failed to merge artists: "+err.Error(), http.StatusInternalServerError)

@ -119,6 +119,40 @@ func TestImportLastFM(t *testing.T) {
truncateTestData(t) truncateTestData(t)
} }
func TestImportLastFM_MbzDisabled(t *testing.T) {
src := path.Join("..", "test_assets", "recenttracks-shoko2-1749776100.json")
destDir := filepath.Join(cfg.ConfigDir(), "import")
dest := filepath.Join(destDir, "recenttracks-shoko2-1749776100.json")
// not going to make the dest dir because engine should make it already
input, err := os.ReadFile(src)
require.NoError(t, err)
require.NoError(t, os.WriteFile(dest, input, os.ModePerm))
engine.RunImporter(logger.Get(), store, &mbz.MbzErrorCaller{})
album, err := store.GetAlbum(context.Background(), db.GetAlbumOpts{MusicBrainzID: uuid.MustParse("e9e78802-0bf8-4ca3-9655-1d943d2d2fa0")})
require.NoError(t, err)
assert.Equal(t, "ZOO!!", album.Title)
artist, err := store.GetArtist(context.Background(), db.GetArtistOpts{MusicBrainzID: uuid.MustParse("4b00640f-3be6-43f8-9b34-ff81bd89320a")})
require.NoError(t, err)
assert.Equal(t, "OurR", artist.Name)
artist, err = store.GetArtist(context.Background(), db.GetArtistOpts{Name: "CHUU"})
require.NoError(t, err)
track, err := store.GetTrack(context.Background(), db.GetTrackOpts{Title: "because I'm stupid?", ArtistIDs: []int32{artist.ID}})
require.NoError(t, err)
t.Log(track)
listens, err := store.GetListensPaginated(context.Background(), db.GetItemsOpts{TrackID: int(track.ID), Period: db.PeriodAllTime})
require.NoError(t, err)
require.Len(t, listens.Items, 1)
assert.WithinDuration(t, time.Unix(1749776100, 0), listens.Items[0].Time, 1*time.Second)
truncateTestData(t)
}
func TestImportListenBrainz(t *testing.T) { func TestImportListenBrainz(t *testing.T) {
src := path.Join("..", "test_assets", "listenbrainz_shoko1_1749780844.zip") src := path.Join("..", "test_assets", "listenbrainz_shoko1_1749780844.zip")
@ -188,3 +222,41 @@ func TestImportListenBrainz(t *testing.T) {
truncateTestData(t) truncateTestData(t)
} }
func TestImportListenBrainz_MbzDisabled(t *testing.T) {
src := path.Join("..", "test_assets", "listenbrainz_shoko1_1749780844.zip")
destDir := filepath.Join(cfg.ConfigDir(), "import")
dest := filepath.Join(destDir, "listenbrainz_shoko1_1749780844.zip")
// not going to make the dest dir because engine should make it already
input, err := os.ReadFile(src)
require.NoError(t, err)
require.NoError(t, os.WriteFile(dest, input, os.ModePerm))
engine.RunImporter(logger.Get(), store, &mbz.MbzErrorCaller{})
album, err := store.GetAlbum(context.Background(), db.GetAlbumOpts{MusicBrainzID: uuid.MustParse("ce330d67-9c46-4a3b-9d62-08406370f234")})
require.NoError(t, err)
assert.Equal(t, "酸欠少女", album.Title)
artist, err := store.GetArtist(context.Background(), db.GetArtistOpts{MusicBrainzID: uuid.MustParse("4b00640f-3be6-43f8-9b34-ff81bd89320a")})
require.NoError(t, err)
assert.Equal(t, "OurR", artist.Name)
artist, err = store.GetArtist(context.Background(), db.GetArtistOpts{MusicBrainzID: uuid.MustParse("09887aa7-226e-4ecc-9a0c-02d2ae5777e1")})
require.NoError(t, err)
assert.Equal(t, "Carly Rae Jepsen", artist.Name)
artist, err = store.GetArtist(context.Background(), db.GetArtistOpts{MusicBrainzID: uuid.MustParse("78e46ae5-9bfd-433b-be3f-19e993d67ecc")})
require.NoError(t, err)
assert.Equal(t, "Rufus Wainwright", artist.Name)
track, err := store.GetTrack(context.Background(), db.GetTrackOpts{MusicBrainzID: uuid.MustParse("08e8f55b-f1a4-46b8-b2d1-fab4c592165c")})
require.NoError(t, err)
assert.Equal(t, "Desert", track.Title)
listens, err := store.GetListensPaginated(context.Background(), db.GetItemsOpts{TrackID: int(track.ID), Period: db.PeriodAllTime})
require.NoError(t, err)
assert.Len(t, listens.Items, 1)
assert.WithinDuration(t, time.Unix(1749780612, 0), listens.Items[0].Time, 1*time.Second)
truncateTestData(t)
}

@ -87,16 +87,16 @@ func ValidateApiKey(store db.DB) func(next http.Handler) http.Handler {
} }
authh := r.Header.Get("Authorization") authh := r.Header.Get("Authorization")
s := strings.Split(authh, "Token ") var token string
if len(s) < 2 { if strings.HasPrefix(strings.ToLower(authh), "token ") {
l.Debug().Msg("ValidateApiKey: Authorization header must be formatted 'Token {token}'") token = strings.TrimSpace(authh[6:]) // strip "Token "
} else {
l.Error().Msg("ValidateApiKey: Authorization header must be formatted 'Token {token}'")
utils.WriteError(w, "unauthorized", http.StatusUnauthorized) utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return return
} }
key := s[1] u, err := store.GetUserByApiKey(ctx, token)
u, err := store.GetUserByApiKey(ctx, key)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to get user from database using api key") l.Err(err).Msg("Failed to get user from database using api key")
utils.WriteError(w, "internal server error", http.StatusInternalServerError) utils.WriteError(w, "internal server error", http.StatusInternalServerError)

@ -1,6 +1,6 @@
module github.com/gabehf/koito module github.com/gabehf/koito
go 1.23.7 go 1.24.2
require ( require (
github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/chi/v5 v5.2.1

@ -3,6 +3,7 @@ package catalog
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"slices" "slices"
"strings" "strings"
@ -17,11 +18,12 @@ import (
) )
type AssociateArtistsOpts struct { type AssociateArtistsOpts struct {
ArtistMbzIDs []uuid.UUID ArtistMbzIDs []uuid.UUID
ArtistNames []string ArtistNames []string
ArtistName string ArtistMbidMap []ArtistMbidMap
TrackTitle string ArtistName string
Mbzc mbz.MusicBrainzCaller TrackTitle string
Mbzc mbz.MusicBrainzCaller
} }
func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) { func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) {
@ -29,9 +31,19 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
var result []*models.Artist var result []*models.Artist
if len(opts.ArtistMbzIDs) > 0 { // use mbid map first, as it is the most reliable way to get mbid for artists
l.Debug().Msg("Associating artists by MusicBrainz ID(s)") if len(opts.ArtistMbidMap) > 0 {
mbzMatches, err := matchArtistsByMBID(ctx, d, opts) l.Debug().Msg("Associating artists by MusicBrainz ID(s) mappings")
mbzMatches, err := matchArtistsByMBIDMappings(ctx, d, opts)
if err != nil {
return nil, err
}
result = append(result, mbzMatches...)
}
if len(opts.ArtistMbzIDs) > len(result) {
l.Debug().Msg("Associating artists by list of MusicBrainz ID(s)")
mbzMatches, err := matchArtistsByMBID(ctx, d, opts, result)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -60,11 +72,82 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
return result, nil return result, nil
} }
func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) { func matchArtistsByMBIDMappings(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) {
l := logger.FromContext(ctx)
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,
})
if err == nil {
l.Debug().Msgf("Artist '%s' found by MusicBrainz ID", artist.Name)
result = append(result, artist)
continue
}
if !errors.Is(err, pgx.ErrNoRows) {
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)
} else {
artist.MbzID = &a.Mbid
}
result = append(result, artist)
continue
}
if !errors.Is(err, pgx.ErrNoRows) {
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)
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")
var imgid uuid.UUID
imgUrl, err := images.GetArtistImage(ctx, images.ArtistImageOpts{
Aliases: []string{a.Artist},
})
if err == nil {
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
}
} else {
l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("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})
if err != nil {
l.Err(fmt.Errorf("matchArtistsByMBIDMappings: %w", err)).Msgf("Failed to create artist '%s' in database", a.Artist)
return nil, fmt.Errorf("matchArtistsByMBIDMappings: %w", err)
}
}
result = append(result, artist)
}
return result, nil
}
func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts, existing []*models.Artist) ([]*models.Artist, error) {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
var result []*models.Artist var result []*models.Artist
for _, id := range opts.ArtistMbzIDs { for _, id := range opts.ArtistMbzIDs {
if artistExistsByMbzID(id, existing) || artistExistsByMbzID(id, result) {
l.Debug().Msgf("Artist with MusicBrainz ID %s already found, skipping...", id)
continue
}
if id == uuid.Nil { if id == uuid.Nil {
l.Warn().Msg("Provided artist has uuid.Nil MusicBrainzID") l.Warn().Msg("Provided artist has uuid.Nil MusicBrainzID")
return matchArtistsByNames(ctx, opts.ArtistNames, result, d) return matchArtistsByNames(ctx, opts.ArtistNames, result, d)
@ -229,3 +312,11 @@ func artistExists(name string, artists []*models.Artist) bool {
} }
return false return false
} }
func artistExistsByMbzID(id uuid.UUID, artists []*models.Artist) bool {
for _, a := range artists {
if a.MbzID != nil && *a.MbzID == id {
return true
}
}
return false
}

@ -29,24 +29,30 @@ type SaveListenOpts struct {
Time time.Time Time time.Time
} }
type ArtistMbidMap struct {
Artist string
Mbid uuid.UUID
}
type SubmitListenOpts struct { type SubmitListenOpts struct {
// When true, skips registering the listen and only associates or creates the // When true, skips registering the listen and only associates or creates the
// artist, release, release group, and track in DB // artist, release, release group, and track in DB
SkipSaveListen bool SkipSaveListen bool
MbzCaller mbz.MusicBrainzCaller MbzCaller mbz.MusicBrainzCaller
ArtistNames []string ArtistNames []string
Artist string Artist string
ArtistMbzIDs []uuid.UUID ArtistMbzIDs []uuid.UUID
TrackTitle string ArtistMbidMappings []ArtistMbidMap
RecordingMbzID uuid.UUID TrackTitle string
Duration int32 // in seconds RecordingMbzID uuid.UUID
ReleaseTitle string Duration int32 // in seconds
ReleaseMbzID uuid.UUID ReleaseTitle string
ReleaseGroupMbzID uuid.UUID ReleaseMbzID uuid.UUID
Time time.Time ReleaseGroupMbzID uuid.UUID
UserID int32 Time time.Time
Client string UserID int32
Client string
} }
const ( const (
@ -64,11 +70,12 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
ctx, ctx,
store, store,
AssociateArtistsOpts{ AssociateArtistsOpts{
ArtistMbzIDs: opts.ArtistMbzIDs, ArtistMbzIDs: opts.ArtistMbzIDs,
ArtistNames: opts.ArtistNames, ArtistNames: opts.ArtistNames,
ArtistName: opts.Artist, ArtistName: opts.Artist,
Mbzc: opts.MbzCaller, ArtistMbidMap: opts.ArtistMbidMappings,
TrackTitle: opts.TrackTitle, Mbzc: opts.MbzCaller,
TrackTitle: opts.TrackTitle,
}) })
if err != nil { if err != nil {
l.Error().Err(err).Msg("Failed to associate artists to listen") l.Error().Err(err).Msg("Failed to associate artists to listen")

@ -30,6 +30,15 @@ const (
ImageCacheDir = "image_cache" ImageCacheDir = "image_cache"
) )
func ImageSourceSize() (size ImageSize) {
if cfg.FullImageCacheEnabled() {
size = ImageSizeFull
} else {
size = ImageSizeLarge
}
return
}
func ParseImageSize(size string) (ImageSize, error) { func ParseImageSize(size string) (ImageSize, error) {
switch strings.ToLower(size) { switch strings.ToLower(size) {
case "small": case "small":

@ -856,3 +856,64 @@ func TestSubmitListen_MusicBrainzUnreachable(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist") assert.True(t, exists, "expected listen row to exist")
} }
func TestSubmitListen_MusicBrainzUnreachableMBIDMappings(t *testing.T) {
truncateTestData(t)
// correctly associate MBID when musicbrainz unreachable, but map provided
ctx := context.Background()
mbzc := &mbz.MbzErrorCaller{}
artistMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
artist2MbzID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
releaseGroupMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000011")
releaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000101")
trackMbzID := uuid.MustParse("00000000-0000-0000-0000-000000001001")
artistMbzIdMap := []catalog.ArtistMbidMap{{Artist: "ATARASHII GAKKO!", Mbid: artistMbzID}, {Artist: "Featured Artist", Mbid: artist2MbzID}}
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!", "Featured Artist"},
Artist: "ATARASHII GAKKO! feat. Featured Artist",
ArtistMbzIDs: []uuid.UUID{
artistMbzID,
},
TrackTitle: "Tokyo Calling",
RecordingMbzID: trackMbzID,
ReleaseTitle: "AG! Calling",
ReleaseMbzID: releaseMbzID,
ReleaseGroupMbzID: releaseGroupMbzID,
ArtistMbidMappings: artistMbzIdMap,
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
// Verify that the artist has the mbid saved
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artists
WHERE musicbrainz_id = $1
)`, artistMbzID)
require.NoError(t, err)
assert.True(t, exists, "expected artist to have correct musicbrainz id")
// Verify that the artist has the mbid saved
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artists
WHERE musicbrainz_id = $1
)`, artist2MbzID)
require.NoError(t, err)
assert.True(t, exists, "expected artist to have correct musicbrainz id")
}

@ -64,6 +64,7 @@ type DB interface {
CountAlbums(ctx context.Context, period Period) (int64, error) CountAlbums(ctx context.Context, period Period) (int64, error)
CountArtists(ctx context.Context, period Period) (int64, error) CountArtists(ctx context.Context, period Period) (int64, error)
CountTimeListened(ctx context.Context, period Period) (int64, error) CountTimeListened(ctx context.Context, period Period) (int64, error)
CountTimeListenedToItem(ctx context.Context, opts TimeListenedOpts) (int64, error)
CountUsers(ctx context.Context) (int64, error) CountUsers(ctx context.Context) (int64, error)
// Search // Search
SearchArtists(ctx context.Context, q string) ([]*models.Artist, error) SearchArtists(ctx context.Context, q string) ([]*models.Artist, error)
@ -71,8 +72,8 @@ type DB interface {
SearchTracks(ctx context.Context, q string) ([]*models.Track, error) SearchTracks(ctx context.Context, q string) ([]*models.Track, error)
// Merge // Merge
MergeTracks(ctx context.Context, fromId, toId int32) error MergeTracks(ctx context.Context, fromId, toId int32) error
MergeAlbums(ctx context.Context, fromId, toId int32) error MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage bool) error
MergeArtists(ctx context.Context, fromId, toId int32) error MergeArtists(ctx context.Context, fromId, toId int32, replaceImage bool) error
// Etc // Etc
ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error) ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error)
GetImageSource(ctx context.Context, image uuid.UUID) (string, error) GetImageSource(ctx context.Context, image uuid.UUID) (string, error)

@ -138,3 +138,10 @@ type ListenActivityOpts struct {
ArtistID int32 ArtistID int32
TrackID int32 TrackID int32
} }
type TimeListenedOpts struct {
Period Period
AlbumID int32
ArtistID int32
TrackID int32
}

@ -57,6 +57,14 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
return nil, err return nil, err
} }
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
AlbumID: row.ID,
})
if err != nil {
return nil, err
}
return &models.Album{ return &models.Album{
ID: row.ID, ID: row.ID,
MbzID: row.MusicBrainzID, MbzID: row.MusicBrainzID,
@ -64,6 +72,7 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
Image: row.Image, Image: row.Image,
VariousArtists: row.VariousArtists, VariousArtists: row.VariousArtists,
ListenCount: count, ListenCount: count,
TimeListened: seconds,
}, nil }, nil
} }

@ -47,21 +47,16 @@ func testDataForRelease(t *testing.T) {
} }
func TestGetAlbum(t *testing.T) { func TestGetAlbum(t *testing.T) {
testDataForRelease(t) testDataForTopItems(t)
ctx := context.Background() ctx := context.Background()
// Insert test data
rg, err := store.SaveAlbum(ctx, db.SaveAlbumOpts{
Title: "Test Release Group",
ArtistIDs: []int32{1},
})
require.NoError(t, err)
// Test GetAlbum by ID // Test GetAlbum by ID
result, err := store.GetAlbum(ctx, db.GetAlbumOpts{ID: rg.ID}) result, err := store.GetAlbum(ctx, db.GetAlbumOpts{ID: 1})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, rg.ID, result.ID) assert.EqualValues(t, 1, result.ID)
assert.Equal(t, "Test Release Group", result.Title) assert.Equal(t, "Release One", result.Title)
assert.EqualValues(t, 4, result.ListenCount)
assert.EqualValues(t, 400, result.TimeListened)
// Test GetAlbum with insufficient information // Test GetAlbum with insufficient information
_, err = store.GetAlbum(ctx, db.GetAlbumOpts{}) _, err = store.GetAlbum(ctx, db.GetAlbumOpts{})

@ -16,6 +16,7 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
// this function sucks because sqlc keeps making new types for rows that are the same
func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Artist, error) { func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Artist, error) {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
if opts.ID != 0 { if opts.ID != 0 {
@ -32,13 +33,21 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil { if err != nil {
return nil, err return nil, err
} }
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
})
if err != nil {
return nil, err
}
return &models.Artist{ return &models.Artist{
ID: row.ID, ID: row.ID,
MbzID: row.MusicBrainzID, MbzID: row.MusicBrainzID,
Name: row.Name, Name: row.Name,
Aliases: row.Aliases, Aliases: row.Aliases,
Image: row.Image, Image: row.Image,
ListenCount: count, ListenCount: count,
TimeListened: seconds,
}, nil }, nil
} else if opts.MusicBrainzID != uuid.Nil { } else if opts.MusicBrainzID != uuid.Nil {
l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID) l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID)
@ -54,13 +63,21 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil { if err != nil {
return nil, err return nil, err
} }
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
})
if err != nil {
return nil, err
}
return &models.Artist{ return &models.Artist{
ID: row.ID, ID: row.ID,
MbzID: row.MusicBrainzID, MbzID: row.MusicBrainzID,
Name: row.Name, Name: row.Name,
Aliases: row.Aliases, Aliases: row.Aliases,
Image: row.Image, Image: row.Image,
ListenCount: count, TimeListened: seconds,
ListenCount: count,
}, nil }, nil
} else if opts.Name != "" { } else if opts.Name != "" {
l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name) l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name)
@ -76,13 +93,21 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil { if err != nil {
return nil, err return nil, err
} }
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
})
if err != nil {
return nil, err
}
return &models.Artist{ return &models.Artist{
ID: row.ID, ID: row.ID,
MbzID: row.MusicBrainzID, MbzID: row.MusicBrainzID,
Name: row.Name, Name: row.Name,
Aliases: row.Aliases, Aliases: row.Aliases,
Image: row.Image, Image: row.Image,
ListenCount: count, ListenCount: count,
TimeListened: seconds,
}, nil }, nil
} else { } else {
return nil, errors.New("insufficient information to get artist") return nil, errors.New("insufficient information to get artist")

@ -13,30 +13,33 @@ import (
) )
func TestGetArtist(t *testing.T) { func TestGetArtist(t *testing.T) {
testDataForTopItems(t)
ctx := context.Background() ctx := context.Background()
mbzId := uuid.MustParse("00000000-0000-0000-0000-000000000001") mbzId := uuid.MustParse("00000000-0000-0000-0000-000000000001")
// Insert test data
artist, err := store.SaveArtist(ctx, db.SaveArtistOpts{
Name: "Test Artist",
MusicBrainzID: mbzId,
})
require.NoError(t, err)
// Test GetArtist by ID // Test GetArtist by ID
result, err := store.GetArtist(ctx, db.GetArtistOpts{ID: artist.ID}) result, err := store.GetArtist(ctx, db.GetArtistOpts{ID: 1})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, artist.ID, result.ID) assert.EqualValues(t, 1, result.ID)
assert.Equal(t, "Test Artist", result.Name) assert.Equal(t, "Artist One", result.Name)
assert.EqualValues(t, 4, result.ListenCount)
assert.EqualValues(t, 400, result.TimeListened)
// Test GetArtist by Name // Test GetArtist by Name
result, err = store.GetArtist(ctx, db.GetArtistOpts{Name: artist.Name}) result, err = store.GetArtist(ctx, db.GetArtistOpts{Name: "Artist One"})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, artist.ID, result.ID) assert.EqualValues(t, 1, result.ID)
assert.Equal(t, "Artist One", result.Name)
assert.EqualValues(t, 4, result.ListenCount)
assert.EqualValues(t, 400, result.TimeListened)
// Test GetArtist by MusicBrainzID // Test GetArtist by MusicBrainzID
result, err = store.GetArtist(ctx, db.GetArtistOpts{MusicBrainzID: mbzId}) result, err = store.GetArtist(ctx, db.GetArtistOpts{MusicBrainzID: mbzId})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, artist.ID, result.ID) assert.EqualValues(t, 1, result.ID)
assert.Equal(t, "Artist One", result.Name)
assert.EqualValues(t, 4, result.ListenCount)
assert.EqualValues(t, 400, result.TimeListened)
// Test GetArtist with insufficient information // Test GetArtist with insufficient information
_, err = store.GetArtist(ctx, db.GetArtistOpts{}) _, err = store.GetArtist(ctx, db.GetArtistOpts{})

@ -2,6 +2,7 @@ package psql
import ( import (
"context" "context"
"errors"
"time" "time"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
@ -68,3 +69,41 @@ func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64,
} }
return count, nil return count, nil
} }
func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(opts.Period)
if opts.ArtistID > 0 {
count, err := p.q.CountTimeListenedToArtist(ctx, repository.CountTimeListenedToArtistParams{
ListenedAt: t1,
ListenedAt_2: t2,
ArtistID: opts.ArtistID,
})
if err != nil {
return 0, err
}
return count, nil
} else if opts.AlbumID > 0 {
count, err := p.q.CountTimeListenedToRelease(ctx, repository.CountTimeListenedToReleaseParams{
ListenedAt: t1,
ListenedAt_2: t2,
ReleaseID: opts.AlbumID,
})
if err != nil {
return 0, err
}
return count, nil
} else if opts.TrackID > 0 {
count, err := p.q.CountTimeListenedToTrack(ctx, repository.CountTimeListenedToTrackParams{
ListenedAt: t1,
ListenedAt_2: t2,
ID: opts.TrackID,
})
if err != nil {
return 0, err
}
return count, nil
}
return 0, errors.New("an id must be provided")
}

@ -74,3 +74,33 @@ func TestCountTimeListened(t *testing.T) {
truncateTestData(t) truncateTestData(t)
} }
func TestCountTimeListenedToArtist(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, ArtistID: 1})
require.NoError(t, err)
assert.EqualValues(t, 400, count)
truncateTestData(t)
}
func TestCountTimeListenedToAlbum(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, AlbumID: 2})
require.NoError(t, err)
assert.EqualValues(t, 300, count)
truncateTestData(t)
}
func TestCountTimeListenedToTrack(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, TrackID: 3})
require.NoError(t, err)
assert.EqualValues(t, 200, count)
truncateTestData(t)
}

@ -2,6 +2,7 @@ package psql
import ( import (
"context" "context"
"fmt"
"github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/repository" "github.com/gabehf/koito/internal/repository"
@ -14,7 +15,7 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error {
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("MergeTracks: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
@ -23,7 +24,7 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error {
TrackID_2: toId, TrackID_2: toId,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("MergeTracks: %w", err)
} }
err = qtx.CleanOrphanedEntries(ctx) err = qtx.CleanOrphanedEntries(ctx)
if err != nil { if err != nil {
@ -33,13 +34,13 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error {
return tx.Commit(ctx) return tx.Commit(ctx)
} }
func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error { func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage bool) error {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Info().Msgf("Merging album %d into album %d", fromId, toId) l.Info().Msgf("Merging album %d into album %d", fromId, toId)
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("MergeAlbums: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
@ -48,7 +49,21 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error {
ReleaseID_2: toId, ReleaseID_2: toId,
}) })
if err != nil { if err != nil {
return err return fmt.Errorf("MergeAlbums: %w", err)
}
if replaceImage {
old, err := qtx.GetRelease(ctx, fromId)
if err != nil {
return fmt.Errorf("MergeAlbums: %w", err)
}
err = qtx.UpdateReleaseImage(ctx, repository.UpdateReleaseImageParams{
ID: toId,
Image: old.Image,
ImageSource: old.ImageSource,
})
if err != nil {
return fmt.Errorf("MergeAlbums: %w", err)
}
} }
err = qtx.CleanOrphanedEntries(ctx) err = qtx.CleanOrphanedEntries(ctx)
if err != nil { if err != nil {
@ -58,13 +73,13 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error {
return tx.Commit(ctx) return tx.Commit(ctx)
} }
func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error { func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32, replaceImage bool) error {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
l.Info().Msgf("Merging artist %d into artist %d", fromId, toId) l.Info().Msgf("Merging artist %d into artist %d", fromId, toId)
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{}) tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
l.Err(err).Msg("Failed to begin transaction") l.Err(err).Msg("Failed to begin transaction")
return err return fmt.Errorf("MergeArtists: %w", err)
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx) qtx := d.q.WithTx(tx)
@ -74,7 +89,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
}) })
if err != nil { if err != nil {
l.Err(err).Msg("Failed to delete conflicting artist tracks") l.Err(err).Msg("Failed to delete conflicting artist tracks")
return err return fmt.Errorf("MergeArtists: %w", err)
} }
err = qtx.DeleteConflictingArtistReleases(ctx, repository.DeleteConflictingArtistReleasesParams{ err = qtx.DeleteConflictingArtistReleases(ctx, repository.DeleteConflictingArtistReleasesParams{
ArtistID: fromId, ArtistID: fromId,
@ -82,7 +97,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
}) })
if err != nil { if err != nil {
l.Err(err).Msg("Failed to delete conflicting artist releases") l.Err(err).Msg("Failed to delete conflicting artist releases")
return err return fmt.Errorf("MergeArtists: %w", err)
} }
err = qtx.UpdateArtistTracks(ctx, repository.UpdateArtistTracksParams{ err = qtx.UpdateArtistTracks(ctx, repository.UpdateArtistTracksParams{
ArtistID: fromId, ArtistID: fromId,
@ -90,7 +105,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
}) })
if err != nil { if err != nil {
l.Err(err).Msg("Failed to update artist tracks") l.Err(err).Msg("Failed to update artist tracks")
return err return fmt.Errorf("MergeArtists: %w", err)
} }
err = qtx.UpdateArtistReleases(ctx, repository.UpdateArtistReleasesParams{ err = qtx.UpdateArtistReleases(ctx, repository.UpdateArtistReleasesParams{
ArtistID: fromId, ArtistID: fromId,
@ -98,12 +113,26 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
}) })
if err != nil { if err != nil {
l.Err(err).Msg("Failed to update artist releases") l.Err(err).Msg("Failed to update artist releases")
return err return fmt.Errorf("MergeArtists: %w", err)
}
if replaceImage {
old, err := qtx.GetArtist(ctx, fromId)
if err != nil {
return fmt.Errorf("MergeAlbums: %w", err)
}
err = qtx.UpdateArtistImage(ctx, repository.UpdateArtistImageParams{
ID: toId,
Image: old.Image,
ImageSource: old.ImageSource,
})
if err != nil {
return fmt.Errorf("MergeAlbums: %w", err)
}
} }
err = qtx.CleanOrphanedEntries(ctx) err = qtx.CleanOrphanedEntries(ctx)
if err != nil { if err != nil {
l.Err(err).Msg("Failed to clean orphaned entries") l.Err(err).Msg("Failed to clean orphaned entries")
return err return fmt.Errorf("MergeArtists: %w", err)
} }
return tx.Commit(ctx) return tx.Commit(ctx)
} }

@ -12,9 +12,9 @@ func setupTestDataForMerge(t *testing.T) {
truncateTestData(t) truncateTestData(t)
// Insert artists // Insert artists
err := store.Exec(context.Background(), err := store.Exec(context.Background(),
`INSERT INTO artists (musicbrainz_id) `INSERT INTO artists (musicbrainz_id, image, image_source)
VALUES ('00000000-0000-0000-0000-000000000001'), VALUES ('00000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000000', 'source.com'),
('00000000-0000-0000-0000-000000000002')`) ('00000000-0000-0000-0000-000000000002', NULL, NULL)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
@ -25,9 +25,9 @@ func setupTestDataForMerge(t *testing.T) {
// Insert albums // Insert albums
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO releases (musicbrainz_id) `INSERT INTO releases (musicbrainz_id, image, image_source)
VALUES ('11111111-1111-1111-1111-111111111111'), VALUES ('11111111-1111-1111-1111-111111111111', '20000000-0000-0000-0000-000000000000', 'source.com'),
('22222222-2222-2222-2222-222222222222')`) ('22222222-2222-2222-2222-222222222222', NULL, NULL)`)
require.NoError(t, err) require.NoError(t, err)
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
@ -90,11 +90,15 @@ func TestMergeAlbums(t *testing.T) {
setupTestDataForMerge(t) setupTestDataForMerge(t)
// Merge Album 1 into Album 2 // Merge Album 1 into Album 2
err := store.MergeAlbums(ctx, 1, 2) err := store.MergeAlbums(ctx, 1, 2, true)
require.NoError(t, err) require.NoError(t, err)
// Verify image was replaced
count, err := store.Count(ctx, `SELECT COUNT(*) FROM releases WHERE image = '20000000-0000-0000-0000-000000000000' AND image_source = 'source.com'`)
require.NoError(t, err)
assert.Equal(t, 1, count, "expected merged release to contain image information")
// Verify tracks are updated // Verify tracks are updated
var count int
count, err = store.Count(ctx, `SELECT COUNT(*) FROM tracks WHERE release_id = 2`) count, err = store.Count(ctx, `SELECT COUNT(*) FROM tracks WHERE release_id = 2`)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 2, count, "expected all tracks to be merged into Album 2") assert.Equal(t, 2, count, "expected all tracks to be merged into Album 2")
@ -107,11 +111,15 @@ func TestMergeArtists(t *testing.T) {
setupTestDataForMerge(t) setupTestDataForMerge(t)
// Merge Artist 1 into Artist 2 // Merge Artist 1 into Artist 2
err := store.MergeArtists(ctx, 1, 2) err := store.MergeArtists(ctx, 1, 2, true)
require.NoError(t, err) require.NoError(t, err)
// Verify image was replaced
count, err := store.Count(ctx, `SELECT COUNT(*) FROM artists WHERE image = '10000000-0000-0000-0000-000000000000' AND image_source = 'source.com'`)
require.NoError(t, err)
assert.Equal(t, 1, count, "expected merged artist to contain image information")
// Verify artist associations are updated // Verify artist associations are updated
var count int
count, err = store.Count(ctx, `SELECT COUNT(*) FROM artist_tracks WHERE artist_id = 2`) count, err = store.Count(ctx, `SELECT COUNT(*) FROM artist_tracks WHERE artist_id = 2`)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 2, count, "expected all tracks to be associated with Artist 2") assert.Equal(t, 2, count, "expected all tracks to be associated with Artist 2")

@ -72,10 +72,19 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
TrackID: track.ID, TrackID: track.ID,
}) })
if err != nil { if err != nil {
l.Err(err).Msgf("Failed to get listen count for track with id %d", track.ID) return nil, err
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
TrackID: track.ID,
})
if err != nil {
return nil, err
} }
track.ListenCount = count track.ListenCount = count
track.TimeListened = seconds
return &track, nil return &track, nil
} }

@ -44,9 +44,9 @@ func testDataForTracks(t *testing.T) {
// Insert tracks // Insert tracks
err = store.Exec(context.Background(), err = store.Exec(context.Background(),
`INSERT INTO tracks (musicbrainz_id, release_id) `INSERT INTO tracks (musicbrainz_id, release_id, duration)
VALUES ('11111111-1111-1111-1111-111111111111', 1), VALUES ('11111111-1111-1111-1111-111111111111', 1, 100),
('22222222-2222-2222-2222-222222222222', 2)`) ('22222222-2222-2222-2222-222222222222', 2, 100)`)
require.NoError(t, err) require.NoError(t, err)
// Insert track aliases // Insert track aliases
@ -61,6 +61,12 @@ func testDataForTracks(t *testing.T) {
`INSERT INTO artist_tracks (artist_id, track_id) `INSERT INTO artist_tracks (artist_id, track_id)
VALUES (1, 1), (2, 2)`) VALUES (1, 1), (2, 2)`)
require.NoError(t, err) require.NoError(t, err)
// Associate tracks with artists
err = store.Exec(context.Background(),
`INSERT INTO listens (user_id, track_id, listened_at)
VALUES (1, 1, NOW()), (1, 2, NOW())`)
require.NoError(t, err)
} }
func TestGetTrack(t *testing.T) { func TestGetTrack(t *testing.T) {
@ -73,12 +79,14 @@ func TestGetTrack(t *testing.T) {
assert.Equal(t, int32(1), track.ID) assert.Equal(t, int32(1), track.ID)
assert.Equal(t, "Track One", track.Title) assert.Equal(t, "Track One", track.Title)
assert.Equal(t, uuid.MustParse("11111111-1111-1111-1111-111111111111"), *track.MbzID) assert.Equal(t, uuid.MustParse("11111111-1111-1111-1111-111111111111"), *track.MbzID)
assert.EqualValues(t, 100, track.TimeListened)
// Test GetTrack by MusicBrainzID // Test GetTrack by MusicBrainzID
track, err = store.GetTrack(ctx, db.GetTrackOpts{MusicBrainzID: uuid.MustParse("22222222-2222-2222-2222-222222222222")}) track, err = store.GetTrack(ctx, db.GetTrackOpts{MusicBrainzID: uuid.MustParse("22222222-2222-2222-2222-222222222222")})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, int32(2), track.ID) assert.Equal(t, int32(2), track.ID)
assert.Equal(t, "Track Two", track.Title) assert.Equal(t, "Track Two", track.Title)
assert.EqualValues(t, 100, track.TimeListened)
// Test GetTrack by Title and ArtistIDs // Test GetTrack by Title and ArtistIDs
track, err = store.GetTrack(ctx, db.GetTrackOpts{ track, err = store.GetTrack(ctx, db.GetTrackOpts{
@ -88,6 +96,7 @@ func TestGetTrack(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, int32(1), track.ID) assert.Equal(t, int32(1), track.ID)
assert.Equal(t, "Track One", track.Title) assert.Equal(t, "Track One", track.Title)
assert.EqualValues(t, 100, track.TimeListened)
// Test GetTrack with insufficient information // Test GetTrack with insufficient information
_, err = store.GetTrack(ctx, db.GetTrackOpts{}) _, err = store.GetTrack(ctx, db.GetTrackOpts{})

@ -97,17 +97,25 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall
l.Debug().Msgf("Skipping import due to import time rules") l.Debug().Msgf("Skipping import due to import time rules")
continue continue
} }
var artistMbidMap []catalog.ArtistMbidMap
if artistMbzID != uuid.Nil {
artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: track.Artist.Text, Mbid: artistMbzID})
}
opts := catalog.SubmitListenOpts{ opts := catalog.SubmitListenOpts{
MbzCaller: mbzc, MbzCaller: mbzc,
Artist: track.Artist.Text, Artist: track.Artist.Text,
ArtistMbzIDs: []uuid.UUID{artistMbzID}, ArtistNames: []string{track.Artist.Text},
TrackTitle: track.Name, ArtistMbzIDs: []uuid.UUID{artistMbzID},
RecordingMbzID: trackMbzID, TrackTitle: track.Name,
ReleaseTitle: album, RecordingMbzID: trackMbzID,
ReleaseMbzID: albumMbzID, ReleaseTitle: album,
Client: "lastfm", ReleaseMbzID: albumMbzID,
Time: ts, ArtistMbidMappings: artistMbidMap,
UserID: 1, Client: "lastfm",
Time: ts,
UserID: 1,
} }
err = catalog.SubmitListen(ctx, store, opts) err = catalog.SubmitListen(ctx, store, opts)
if err != nil { if err != nil {

@ -113,20 +113,34 @@ func ImportListenBrainzFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrai
} else if payload.TrackMeta.AdditionalInfo.DurationMs != 0 { } else if payload.TrackMeta.AdditionalInfo.DurationMs != 0 {
duration = payload.TrackMeta.AdditionalInfo.DurationMs / 1000 duration = payload.TrackMeta.AdditionalInfo.DurationMs / 1000
} }
var artistMbidMap []catalog.ArtistMbidMap
for _, a := range payload.TrackMeta.MBIDMapping.Artists {
if a.ArtistMBID == "" || a.ArtistName == "" {
continue
}
mbid, err := uuid.Parse(a.ArtistMBID)
if err != nil {
l.Err(err).Msgf("LbzSubmitListenHandler: Failed to parse UUID for artist '%s'", a.ArtistName)
}
artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: a.ArtistName, Mbid: mbid})
}
opts := catalog.SubmitListenOpts{ opts := catalog.SubmitListenOpts{
MbzCaller: mbzc, MbzCaller: mbzc,
ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames, ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames,
Artist: payload.TrackMeta.ArtistName, Artist: payload.TrackMeta.ArtistName,
ArtistMbzIDs: artistMbzIDs, ArtistMbzIDs: artistMbzIDs,
TrackTitle: payload.TrackMeta.TrackName, TrackTitle: payload.TrackMeta.TrackName,
RecordingMbzID: recordingMbzID, RecordingMbzID: recordingMbzID,
ReleaseTitle: payload.TrackMeta.ReleaseName, ReleaseTitle: payload.TrackMeta.ReleaseName,
ReleaseMbzID: releaseMbzID, ReleaseMbzID: releaseMbzID,
ReleaseGroupMbzID: rgMbzID, ReleaseGroupMbzID: rgMbzID,
Duration: duration, ArtistMbidMappings: artistMbidMap,
Time: ts, Duration: duration,
UserID: 1, Time: ts,
Client: client, UserID: 1,
Client: client,
} }
err = catalog.SubmitListen(ctx, store, opts) err = catalog.SubmitListen(ctx, store, opts)
if err != nil { if err != nil {

@ -10,6 +10,7 @@ type Album struct {
Artists []SimpleArtist `json:"artists"` Artists []SimpleArtist `json:"artists"`
VariousArtists bool `json:"is_various_artists"` VariousArtists bool `json:"is_various_artists"`
ListenCount int64 `json:"listen_count"` ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"`
} }
// type SimpleAlbum struct { // type SimpleAlbum struct {

@ -3,12 +3,13 @@ package models
import "github.com/google/uuid" import "github.com/google/uuid"
type Artist struct { type Artist struct {
ID int32 `json:"id"` ID int32 `json:"id"`
MbzID *uuid.UUID `json:"musicbrainz_id"` MbzID *uuid.UUID `json:"musicbrainz_id"`
Name string `json:"name"` Name string `json:"name"`
Aliases []string `json:"aliases"` Aliases []string `json:"aliases"`
Image *uuid.UUID `json:"image"` Image *uuid.UUID `json:"image"`
ListenCount int64 `json:"listen_count"` ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"`
} }
type SimpleArtist struct { type SimpleArtist struct {

@ -3,12 +3,13 @@ package models
import "github.com/google/uuid" import "github.com/google/uuid"
type Track struct { type Track struct {
ID int32 `json:"id"` ID int32 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Artists []SimpleArtist `json:"artists"` Artists []SimpleArtist `json:"artists"`
MbzID *uuid.UUID `json:"musicbrainz_id"` MbzID *uuid.UUID `json:"musicbrainz_id"`
ListenCount int64 `json:"listen_count"` ListenCount int64 `json:"listen_count"`
Duration int32 `json:"duration"` Duration int32 `json:"duration"`
Image *uuid.UUID `json:"image"` Image *uuid.UUID `json:"image"`
AlbumID int32 `json:"album_id"` AlbumID int32 `json:"album_id"`
TimeListened int64 `json:"time_listened"`
} }

Loading…
Cancel
Save