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

@ -82,7 +82,7 @@ function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artis
<span className="color-fg-secondary">Various Artists</span>
:
<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 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 [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0})
const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
const [replaceImage, setReplaceImage] = useState(false)
const navigate = useNavigate()
@ -53,7 +54,7 @@ export default function MergeModal(props: Props) {
from = {id: props.currentId, title: props.currentTitle}
to = mergeTarget
}
props.mergeFunc(from.id, to.id)
props.mergeFunc(from.id, to.id, replaceImage)
.then(r => {
if (r.ok) {
if (mergeOrderReversed) {
@ -117,6 +118,13 @@ export default function MergeModal(props: Props) {
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} />
<label htmlFor="reverse-merge-order">Reverse merge order</label>
</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>

@ -6,6 +6,7 @@ import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils";
export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
@ -40,9 +41,10 @@ export default function Album() {
}
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>}
</>}
{<p>{timeListenedString(album.time_listened)}</p>}
</div>}
>
<div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} />

@ -7,6 +7,7 @@ import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ArtistAlbums from "~/components/ArtistAlbums";
import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils";
export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
@ -46,9 +47,10 @@ export default function Artist() {
}
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>}
</>}
{<p>{timeListenedString(artist.time_listened)}</p>}
</div>}
>
<div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} />

@ -9,7 +9,7 @@ import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
import DeleteModal from "~/components/modals/DeleteModal";
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
interface Props {

@ -5,6 +5,7 @@ import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils";
export async function clientLoader({ params }: LoaderFunctionArgs) {
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
@ -42,9 +43,10 @@ export default function Track() {
}
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>
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
{<p>{timeListenedString(track.time_listened)}</p>}
</div>}
>
<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}

@ -12,8 +12,7 @@ Koito currently supports the following sources to import data from:
:::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
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,
which can lead to some artist matching issues, especially for people who listen to lots of foreign music. You can also use
means that artist aliases will not be automatically fetched for imported artists. You can also use
[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.
:::

@ -42,8 +42,19 @@ type LbzTrackMeta struct {
ArtistName string `json:"artist_name"` // required
TrackName string `json:"track_name"` // required
ReleaseName string `json:"release_name,omitempty"`
MBIDMapping LbzMBIDMapping `json:"mbid_mapping"`
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 {
MediaPlayer string `json:"media_player,omitempty"`
@ -128,17 +139,30 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
if err != nil {
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)
if err != nil {
rgMbzID = uuid.Nil
}
releaseMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseMBID)
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)
if err != nil {
recordingMbzID = uuid.Nil
recordingMbzID, err = uuid.Parse(payload.TrackMeta.MBIDMapping.RecordingMBID)
if err != nil {
recordingMbzID = uuid.Nil
}
}
var client string
@ -160,20 +184,33 @@ func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http
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{
MbzCaller: mbzc,
ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames,
Artist: payload.TrackMeta.ArtistName,
ArtistMbzIDs: artistMbzIDs,
TrackTitle: payload.TrackMeta.TrackName,
RecordingMbzID: recordingMbzID,
ReleaseTitle: payload.TrackMeta.ReleaseName,
ReleaseMbzID: releaseMbzID,
ReleaseGroupMbzID: rgMbzID,
Duration: duration,
Time: listenedAt,
UserID: u.ID,
Client: client,
MbzCaller: mbzc,
ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames,
Artist: payload.TrackMeta.ArtistName,
ArtistMbzIDs: artistMbzIDs,
TrackTitle: payload.TrackMeta.TrackName,
RecordingMbzID: recordingMbzID,
ReleaseTitle: payload.TrackMeta.ReleaseName,
ReleaseMbzID: releaseMbzID,
ReleaseGroupMbzID: rgMbzID,
ArtistMbidMappings: artistMbidMap,
Duration: duration,
Time: listenedAt,
UserID: u.ID,
Client: client,
}
if req.ListenType == ListenTypePlayingNow {

@ -3,6 +3,7 @@ package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
@ -67,9 +68,16 @@ func MergeReleaseGroupsHandler(store db.DB) http.HandlerFunc {
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)
err = store.MergeAlbums(r.Context(), int32(fromId), int32(toId))
err = store.MergeAlbums(r.Context(), int32(fromId), int32(toId), replaceImage)
if err != nil {
l.Err(err).Msg("MergeReleaseGroupsHandler: Failed to merge release groups")
utils.WriteError(w, "Failed to merge release groups: "+err.Error(), http.StatusInternalServerError)
@ -103,9 +111,16 @@ func MergeArtistsHandler(store db.DB) http.HandlerFunc {
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)
err = store.MergeArtists(r.Context(), int32(fromId), int32(toId))
err = store.MergeArtists(r.Context(), int32(fromId), int32(toId), replaceImage)
if err != nil {
l.Err(err).Msg("MergeArtistsHandler: Failed to merge artists")
utils.WriteError(w, "Failed to merge artists: "+err.Error(), http.StatusInternalServerError)

@ -119,6 +119,40 @@ func TestImportLastFM(t *testing.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) {
src := path.Join("..", "test_assets", "listenbrainz_shoko1_1749780844.zip")
@ -188,3 +222,41 @@ func TestImportListenBrainz(t *testing.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")
s := strings.Split(authh, "Token ")
if len(s) < 2 {
l.Debug().Msg("ValidateApiKey: Authorization header must be formatted 'Token {token}'")
var token string
if strings.HasPrefix(strings.ToLower(authh), "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)
return
}
key := s[1]
u, err := store.GetUserByApiKey(ctx, key)
u, err := store.GetUserByApiKey(ctx, token)
if err != nil {
l.Err(err).Msg("Failed to get user from database using api key")
utils.WriteError(w, "internal server error", http.StatusInternalServerError)

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

@ -3,6 +3,7 @@ package catalog
import (
"context"
"errors"
"fmt"
"slices"
"strings"
@ -17,11 +18,12 @@ import (
)
type AssociateArtistsOpts struct {
ArtistMbzIDs []uuid.UUID
ArtistNames []string
ArtistName string
TrackTitle string
Mbzc mbz.MusicBrainzCaller
ArtistMbzIDs []uuid.UUID
ArtistNames []string
ArtistMbidMap []ArtistMbidMap
ArtistName string
TrackTitle string
Mbzc mbz.MusicBrainzCaller
}
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
if len(opts.ArtistMbzIDs) > 0 {
l.Debug().Msg("Associating artists by MusicBrainz ID(s)")
mbzMatches, err := matchArtistsByMBID(ctx, d, opts)
// use mbid map first, as it is the most reliable way to get mbid for artists
if len(opts.ArtistMbidMap) > 0 {
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 {
return nil, err
}
@ -60,11 +72,82 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
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)
var result []*models.Artist
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 {
l.Warn().Msg("Provided artist has uuid.Nil MusicBrainzID")
return matchArtistsByNames(ctx, opts.ArtistNames, result, d)
@ -229,3 +312,11 @@ func artistExists(name string, artists []*models.Artist) bool {
}
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
}
type ArtistMbidMap struct {
Artist string
Mbid uuid.UUID
}
type SubmitListenOpts struct {
// When true, skips registering the listen and only associates or creates the
// artist, release, release group, and track in DB
SkipSaveListen bool
MbzCaller mbz.MusicBrainzCaller
ArtistNames []string
Artist string
ArtistMbzIDs []uuid.UUID
TrackTitle string
RecordingMbzID uuid.UUID
Duration int32 // in seconds
ReleaseTitle string
ReleaseMbzID uuid.UUID
ReleaseGroupMbzID uuid.UUID
Time time.Time
UserID int32
Client string
MbzCaller mbz.MusicBrainzCaller
ArtistNames []string
Artist string
ArtistMbzIDs []uuid.UUID
ArtistMbidMappings []ArtistMbidMap
TrackTitle string
RecordingMbzID uuid.UUID
Duration int32 // in seconds
ReleaseTitle string
ReleaseMbzID uuid.UUID
ReleaseGroupMbzID uuid.UUID
Time time.Time
UserID int32
Client string
}
const (
@ -64,11 +70,12 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
ctx,
store,
AssociateArtistsOpts{
ArtistMbzIDs: opts.ArtistMbzIDs,
ArtistNames: opts.ArtistNames,
ArtistName: opts.Artist,
Mbzc: opts.MbzCaller,
TrackTitle: opts.TrackTitle,
ArtistMbzIDs: opts.ArtistMbzIDs,
ArtistNames: opts.ArtistNames,
ArtistName: opts.Artist,
ArtistMbidMap: opts.ArtistMbidMappings,
Mbzc: opts.MbzCaller,
TrackTitle: opts.TrackTitle,
})
if err != nil {
l.Error().Err(err).Msg("Failed to associate artists to listen")

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

@ -856,3 +856,64 @@ func TestSubmitListen_MusicBrainzUnreachable(t *testing.T) {
require.NoError(t, err)
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)
CountArtists(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)
// Search
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)
// Merge
MergeTracks(ctx context.Context, fromId, toId int32) error
MergeAlbums(ctx context.Context, fromId, toId int32) error
MergeArtists(ctx context.Context, fromId, toId int32) error
MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage bool) error
MergeArtists(ctx context.Context, fromId, toId int32, replaceImage bool) error
// Etc
ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error)
GetImageSource(ctx context.Context, image uuid.UUID) (string, error)

@ -138,3 +138,10 @@ type ListenActivityOpts struct {
ArtistID 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
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
AlbumID: row.ID,
})
if err != nil {
return nil, err
}
return &models.Album{
ID: row.ID,
MbzID: row.MusicBrainzID,
@ -64,6 +72,7 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
Image: row.Image,
VariousArtists: row.VariousArtists,
ListenCount: count,
TimeListened: seconds,
}, nil
}

@ -47,21 +47,16 @@ func testDataForRelease(t *testing.T) {
}
func TestGetAlbum(t *testing.T) {
testDataForRelease(t)
testDataForTopItems(t)
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
result, err := store.GetAlbum(ctx, db.GetAlbumOpts{ID: rg.ID})
result, err := store.GetAlbum(ctx, db.GetAlbumOpts{ID: 1})
require.NoError(t, err)
assert.Equal(t, rg.ID, result.ID)
assert.Equal(t, "Test Release Group", result.Title)
assert.EqualValues(t, 1, result.ID)
assert.Equal(t, "Release One", result.Title)
assert.EqualValues(t, 4, result.ListenCount)
assert.EqualValues(t, 400, result.TimeListened)
// Test GetAlbum with insufficient information
_, err = store.GetAlbum(ctx, db.GetAlbumOpts{})

@ -16,6 +16,7 @@ import (
"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) {
l := logger.FromContext(ctx)
if opts.ID != 0 {
@ -32,13 +33,21 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil {
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{
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
ListenCount: count,
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
ListenCount: count,
TimeListened: seconds,
}, nil
} else if opts.MusicBrainzID != uuid.Nil {
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 {
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{
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
ListenCount: count,
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
TimeListened: seconds,
ListenCount: count,
}, nil
} else if 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 {
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{
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
ListenCount: count,
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
ListenCount: count,
TimeListened: seconds,
}, nil
} else {
return nil, errors.New("insufficient information to get artist")

@ -13,30 +13,33 @@ import (
)
func TestGetArtist(t *testing.T) {
testDataForTopItems(t)
ctx := context.Background()
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
result, err := store.GetArtist(ctx, db.GetArtistOpts{ID: artist.ID})
result, err := store.GetArtist(ctx, db.GetArtistOpts{ID: 1})
require.NoError(t, err)
assert.Equal(t, artist.ID, result.ID)
assert.Equal(t, "Test Artist", result.Name)
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 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)
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
result, err = store.GetArtist(ctx, db.GetArtistOpts{MusicBrainzID: mbzId})
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
_, err = store.GetArtist(ctx, db.GetArtistOpts{})

@ -2,6 +2,7 @@ package psql
import (
"context"
"errors"
"time"
"github.com/gabehf/koito/internal/db"
@ -68,3 +69,41 @@ func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64,
}
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)
}
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 (
"context"
"fmt"
"github.com/gabehf/koito/internal/logger"
"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{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("MergeTracks: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
@ -23,7 +24,7 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error {
TrackID_2: toId,
})
if err != nil {
return err
return fmt.Errorf("MergeTracks: %w", err)
}
err = qtx.CleanOrphanedEntries(ctx)
if err != nil {
@ -33,13 +34,13 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error {
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.Info().Msgf("Merging album %d into album %d", fromId, toId)
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("MergeAlbums: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
@ -48,7 +49,21 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error {
ReleaseID_2: toId,
})
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)
if err != nil {
@ -58,13 +73,13 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error {
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.Info().Msgf("Merging artist %d into artist %d", fromId, toId)
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("MergeArtists: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
@ -74,7 +89,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
})
if err != nil {
l.Err(err).Msg("Failed to delete conflicting artist tracks")
return err
return fmt.Errorf("MergeArtists: %w", err)
}
err = qtx.DeleteConflictingArtistReleases(ctx, repository.DeleteConflictingArtistReleasesParams{
ArtistID: fromId,
@ -82,7 +97,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
})
if err != nil {
l.Err(err).Msg("Failed to delete conflicting artist releases")
return err
return fmt.Errorf("MergeArtists: %w", err)
}
err = qtx.UpdateArtistTracks(ctx, repository.UpdateArtistTracksParams{
ArtistID: fromId,
@ -90,7 +105,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
})
if err != nil {
l.Err(err).Msg("Failed to update artist tracks")
return err
return fmt.Errorf("MergeArtists: %w", err)
}
err = qtx.UpdateArtistReleases(ctx, repository.UpdateArtistReleasesParams{
ArtistID: fromId,
@ -98,12 +113,26 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
})
if err != nil {
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)
if err != nil {
l.Err(err).Msg("Failed to clean orphaned entries")
return err
return fmt.Errorf("MergeArtists: %w", err)
}
return tx.Commit(ctx)
}

@ -12,9 +12,9 @@ func setupTestDataForMerge(t *testing.T) {
truncateTestData(t)
// Insert artists
err := store.Exec(context.Background(),
`INSERT INTO artists (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000002')`)
`INSERT INTO artists (musicbrainz_id, image, image_source)
VALUES ('00000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000000', 'source.com'),
('00000000-0000-0000-0000-000000000002', NULL, NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
@ -25,9 +25,9 @@ func setupTestDataForMerge(t *testing.T) {
// Insert albums
err = store.Exec(context.Background(),
`INSERT INTO releases (musicbrainz_id)
VALUES ('11111111-1111-1111-1111-111111111111'),
('22222222-2222-2222-2222-222222222222')`)
`INSERT INTO releases (musicbrainz_id, image, image_source)
VALUES ('11111111-1111-1111-1111-111111111111', '20000000-0000-0000-0000-000000000000', 'source.com'),
('22222222-2222-2222-2222-222222222222', NULL, NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
@ -90,11 +90,15 @@ func TestMergeAlbums(t *testing.T) {
setupTestDataForMerge(t)
// Merge Album 1 into Album 2
err := store.MergeAlbums(ctx, 1, 2)
err := store.MergeAlbums(ctx, 1, 2, true)
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
var count int
count, err = store.Count(ctx, `SELECT COUNT(*) FROM tracks WHERE release_id = 2`)
require.NoError(t, err)
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)
// Merge Artist 1 into Artist 2
err := store.MergeArtists(ctx, 1, 2)
err := store.MergeArtists(ctx, 1, 2, true)
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
var count int
count, err = store.Count(ctx, `SELECT COUNT(*) FROM artist_tracks WHERE artist_id = 2`)
require.NoError(t, err)
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,
})
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.TimeListened = seconds
return &track, nil
}

@ -44,9 +44,9 @@ func testDataForTracks(t *testing.T) {
// Insert tracks
err = store.Exec(context.Background(),
`INSERT INTO tracks (musicbrainz_id, release_id)
VALUES ('11111111-1111-1111-1111-111111111111', 1),
('22222222-2222-2222-2222-222222222222', 2)`)
`INSERT INTO tracks (musicbrainz_id, release_id, duration)
VALUES ('11111111-1111-1111-1111-111111111111', 1, 100),
('22222222-2222-2222-2222-222222222222', 2, 100)`)
require.NoError(t, err)
// Insert track aliases
@ -61,6 +61,12 @@ func testDataForTracks(t *testing.T) {
`INSERT INTO artist_tracks (artist_id, track_id)
VALUES (1, 1), (2, 2)`)
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) {
@ -73,12 +79,14 @@ func TestGetTrack(t *testing.T) {
assert.Equal(t, int32(1), track.ID)
assert.Equal(t, "Track One", track.Title)
assert.Equal(t, uuid.MustParse("11111111-1111-1111-1111-111111111111"), *track.MbzID)
assert.EqualValues(t, 100, track.TimeListened)
// Test GetTrack by MusicBrainzID
track, err = store.GetTrack(ctx, db.GetTrackOpts{MusicBrainzID: uuid.MustParse("22222222-2222-2222-2222-222222222222")})
require.NoError(t, err)
assert.Equal(t, int32(2), track.ID)
assert.Equal(t, "Track Two", track.Title)
assert.EqualValues(t, 100, track.TimeListened)
// Test GetTrack by Title and ArtistIDs
track, err = store.GetTrack(ctx, db.GetTrackOpts{
@ -88,6 +96,7 @@ func TestGetTrack(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, int32(1), track.ID)
assert.Equal(t, "Track One", track.Title)
assert.EqualValues(t, 100, track.TimeListened)
// Test GetTrack with insufficient information
_, 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")
continue
}
var artistMbidMap []catalog.ArtistMbidMap
if artistMbzID != uuid.Nil {
artistMbidMap = append(artistMbidMap, catalog.ArtistMbidMap{Artist: track.Artist.Text, Mbid: artistMbzID})
}
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
Artist: track.Artist.Text,
ArtistMbzIDs: []uuid.UUID{artistMbzID},
TrackTitle: track.Name,
RecordingMbzID: trackMbzID,
ReleaseTitle: album,
ReleaseMbzID: albumMbzID,
Client: "lastfm",
Time: ts,
UserID: 1,
MbzCaller: mbzc,
Artist: track.Artist.Text,
ArtistNames: []string{track.Artist.Text},
ArtistMbzIDs: []uuid.UUID{artistMbzID},
TrackTitle: track.Name,
RecordingMbzID: trackMbzID,
ReleaseTitle: album,
ReleaseMbzID: albumMbzID,
ArtistMbidMappings: artistMbidMap,
Client: "lastfm",
Time: ts,
UserID: 1,
}
err = catalog.SubmitListen(ctx, store, opts)
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 {
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{
MbzCaller: mbzc,
ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames,
Artist: payload.TrackMeta.ArtistName,
ArtistMbzIDs: artistMbzIDs,
TrackTitle: payload.TrackMeta.TrackName,
RecordingMbzID: recordingMbzID,
ReleaseTitle: payload.TrackMeta.ReleaseName,
ReleaseMbzID: releaseMbzID,
ReleaseGroupMbzID: rgMbzID,
Duration: duration,
Time: ts,
UserID: 1,
Client: client,
MbzCaller: mbzc,
ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames,
Artist: payload.TrackMeta.ArtistName,
ArtistMbzIDs: artistMbzIDs,
TrackTitle: payload.TrackMeta.TrackName,
RecordingMbzID: recordingMbzID,
ReleaseTitle: payload.TrackMeta.ReleaseName,
ReleaseMbzID: releaseMbzID,
ReleaseGroupMbzID: rgMbzID,
ArtistMbidMappings: artistMbidMap,
Duration: duration,
Time: ts,
UserID: 1,
Client: client,
}
err = catalog.SubmitListen(ctx, store, opts)
if err != nil {

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

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

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

Loading…
Cancel
Save