Koito/internal/catalog/catalog.go
Gabe Farrell 36f984a1a2
Pre-release version v0.0.14 (#96)
* add dev branch container to workflow

* correctly set the default range of ActivityGrid

* fix: set name/short_name to koito (#61)

* fix dev container push workflow

* fix: race condition with using getComputedStyle primary color for dynamic activity grid darkening (#76)

* Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening

Instead just use the color from the current theme directly. Tested works on initial load and theme changes.
Fixes https://github.com/gabehf/Koito/issues/75

* Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name
Split name out of the Theme struct to simplify custom theme saving/reading

* fix: set first artist listed as primary by default (#81)

* feat: add server-side configuration with default theme (#90)

* docs: add example for usage of the main listenbrainz instance (#71)

* docs: add example for usage of the main listenbrainz instance

* Update scrobbler.md

---------

Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com>

* feat: add server-side cfg and default theme

* fix: repair custom theme

---------

Co-authored-by: m0d3rnX <jesper@posteo.de>

* docs: add default theme cfg option to docs

* feat: add ability to manually scrobble track (#91)

* feat: add button to manually scrobble from ui

* fix: ensure timestamp is in the past, log fix

* test: add integration test

* feat: add first listened to dates for media items (#92)

* fix: ensure error checks for ErrNoRows

* feat: add now playing endpoint and ui (#93)

* wip

* feat: add now playing

* fix: set default theme when config is not set

* feat: fetch images from subsonic server (#94)

* fix: useQuery instead of useEffect for now playing

* feat: custom artist separator regex (#95)

* Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening

Instead just use the color from the current theme directly. Tested works on initial load and theme changes.
Fixes https://github.com/gabehf/Koito/issues/75

* Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name
Split name out of the Theme struct to simplify custom theme saving/reading

* feat: add server-side configuration with default theme (#90)

* docs: add example for usage of the main listenbrainz instance (#71)

* docs: add example for usage of the main listenbrainz instance

* Update scrobbler.md

---------

Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com>

* feat: add server-side cfg and default theme

* fix: repair custom theme

---------

Co-authored-by: m0d3rnX <jesper@posteo.de>

* fix: rebase errors

---------

Co-authored-by: pet <128837728+againstpetra@users.noreply.github.com>
Co-authored-by: mlandry <mike.landry@gmail.com>
Co-authored-by: m0d3rnX <jesper@posteo.de>
2025-11-19 20:26:56 -05:00

277 lines
7.5 KiB
Go

// Package catalog manages the internal metadata of the catalog of music the user has submitted listens for.
// This includes artists, releases (album, single, ep, etc), and tracks, as well as ingesting
// listens submitted both via the API(s) and other methods.
package catalog
import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/mbz"
"github.com/gabehf/koito/internal/memkv"
"github.com/gabehf/koito/internal/models"
"github.com/google/uuid"
)
type GetListensOpts struct {
ArtistID int32
ReleaseGroupID int32
TrackID int32
Limit int
}
type SaveListenOpts struct {
TrackID int32
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
// When true, skips caching the images and only stores the image url in the db
SkipCacheImage bool
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
IsNowPlaying bool
}
const (
ImageSourceUserUpload = "User Upload"
)
func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error {
l := logger.FromContext(ctx)
if opts.Artist == "" || opts.TrackTitle == "" {
return errors.New("track name and artist are required")
}
// bandaid to ensure new activity does not have sub-second precision
opts.Time = opts.Time.Truncate(time.Second)
artists, err := AssociateArtists(
ctx,
store,
AssociateArtistsOpts{
ArtistMbzIDs: opts.ArtistMbzIDs,
ArtistNames: opts.ArtistNames,
ArtistName: opts.Artist,
ArtistMbidMap: opts.ArtistMbidMappings,
Mbzc: opts.MbzCaller,
TrackTitle: opts.TrackTitle,
SkipCacheImage: opts.SkipCacheImage,
})
if err != nil {
l.Err(err).Msg("Failed to associate artists to listen")
return fmt.Errorf("SubmitListen: %w", err)
} else if len(artists) < 1 {
l.Debug().Msg("Failed to associate any artists to release")
}
artistIDs := make([]int32, len(artists))
for i, artist := range artists {
artistIDs[i] = artist.ID
l.Debug().Any("artist", artist).Msg("Matched listen to artist")
}
rg, err := AssociateAlbum(ctx, store, AssociateAlbumOpts{
ReleaseMbzID: opts.ReleaseMbzID,
ReleaseGroupMbzID: opts.ReleaseGroupMbzID,
ReleaseName: opts.ReleaseTitle,
TrackName: opts.TrackTitle,
Mbzc: opts.MbzCaller,
Artists: artists,
SkipCacheImage: opts.SkipCacheImage,
})
if err != nil {
l.Error().Err(err).Msg("Failed to associate release group to listen")
return fmt.Errorf("SubmitListen: %w", err)
}
l.Debug().Any("album", rg).Msg("Matched listen to release")
// ensure artists are associated with release group
store.AddArtistsToAlbum(ctx, db.AddArtistsToAlbumOpts{
ArtistIDs: artistIDs,
AlbumID: rg.ID,
})
track, err := AssociateTrack(ctx, store, AssociateTrackOpts{
ArtistIDs: artistIDs,
AlbumID: rg.ID,
TrackMbzID: opts.RecordingMbzID,
TrackName: opts.TrackTitle,
Duration: opts.Duration,
Mbzc: opts.MbzCaller,
})
if err != nil {
l.Error().Err(err).Msg("Failed to associate track to listen")
return fmt.Errorf("SubmitListen: %w", err)
}
l.Debug().Any("track", track).Msg("Matched listen to track")
if track.Duration == 0 {
if opts.Duration != 0 {
l.Debug().Msg("Updating duration using request information")
err := store.UpdateTrack(ctx, db.UpdateTrackOpts{
ID: track.ID,
Duration: opts.Duration,
})
if err != nil {
l.Err(err).Msgf("Failed to update duration for track %s", track.Title)
} else {
l.Info().Msgf("Duration updated to %d for track '%s'", opts.Duration, track.Title)
}
} else if track.MbzID != nil && *track.MbzID != uuid.Nil {
l.Debug().Msg("Attempting to update duration using MusicBrainz ID")
mbztrack, err := opts.MbzCaller.GetTrack(ctx, *track.MbzID)
if err != nil {
l.Err(err).Msg("Failed to make request to MusicBrainz")
} else {
err = store.UpdateTrack(ctx, db.UpdateTrackOpts{
ID: track.ID,
Duration: int32(mbztrack.LengthMs / 1000),
})
if err != nil {
l.Err(err).Msgf("Failed to update duration for track %s", track.Title)
} else {
l.Info().Msgf("Duration updated to %d for track '%s'", mbztrack.LengthMs/1000, track.Title)
}
}
}
}
if opts.IsNowPlaying {
if track.Duration == 0 {
memkv.Store.Set(strconv.Itoa(int(opts.UserID)), track.ID)
} else {
memkv.Store.Set(strconv.Itoa(int(opts.UserID)), track.ID, time.Duration(track.Duration)*time.Second)
}
}
if opts.SkipSaveListen {
return nil
}
l.Info().Msgf("Received listen: '%s' by %s, from release '%s'", track.Title, buildArtistStr(artists), rg.Title)
return store.SaveListen(ctx, db.SaveListenOpts{
TrackID: track.ID,
Time: opts.Time,
UserID: opts.UserID,
Client: opts.Client,
})
}
func buildArtistStr(artists []*models.Artist) string {
artistNames := make([]string, len(artists))
for i, artist := range artists {
artistNames[i] = artist.Name
}
return strings.Join(artistNames, " & ")
}
var (
// Bracketed feat patterns
bracketFeatPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\([fF]eat\. ([^)]*)\)`),
regexp.MustCompile(`(?i)\[[fF]eat\. ([^\]]*)\]`),
}
// Inline feat (not in brackets)
inlineFeatPattern = regexp.MustCompile(`(?i)[fF]eat\. ([^()\[\]]+)$`)
// Delimiters only used inside feat. sections
featSplitDelimiters = regexp.MustCompile(`(?i)\s*(?:,|&|and|·)\s*`)
)
// ParseArtists extracts all contributing artist names from the artist and title strings
func ParseArtists(artist string, title string, addlSeparators []*regexp.Regexp) []string {
seen := make(map[string]struct{})
var out []string
add := func(name string) {
name = strings.TrimSpace(name)
if name == "" {
return
}
if _, exists := seen[name]; !exists {
seen[name] = struct{}{}
out = append(out, name)
}
}
// Extract bracketed features from artist
for _, re := range bracketFeatPatterns {
if matches := re.FindStringSubmatch(artist); matches != nil {
artist = strings.Replace(artist, matches[0], "", 1)
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
}
}
}
// Extract inline feat. from artist
if matches := inlineFeatPattern.FindStringSubmatch(artist); matches != nil {
artist = strings.Replace(artist, matches[0], "", 1)
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
}
}
// Add base artist(s)
l1 := len(out)
for _, re := range addlSeparators {
for _, name := range re.Split(artist, -1) {
if name == artist {
continue
}
add(name)
}
}
// Only add the full artist string if no splitters were matched
if l1 == len(out) {
add(artist)
}
// Extract features from title
for _, re := range bracketFeatPatterns {
if matches := re.FindStringSubmatch(title); matches != nil {
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
}
}
}
if matches := inlineFeatPattern.FindStringSubmatch(title); matches != nil {
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
}
}
return out
}