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>
This commit is contained in:
Gabe Farrell 2025-11-19 20:26:56 -05:00 committed by GitHub
parent bf0ec68cfe
commit 36f984a1a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1887 additions and 906 deletions

View file

@ -62,7 +62,7 @@ func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) (
}
if len(result) < 1 {
allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle, cfg.ArtistSeparators()))
l.Debug().Msgf("Associating artists by artist name(s) %v and track title '%s'", allArtists, opts.TrackTitle)
fallbackMatches, err := matchArtistsByNames(ctx, allArtists, nil, d, opts)
if err != nil {
@ -180,7 +180,7 @@ func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts,
}
if len(opts.ArtistNames) < 1 {
opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle, cfg.ArtistSeparators()))
}
a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts)

View file

@ -8,12 +8,14 @@ import (
"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"
)
@ -56,8 +58,9 @@ type SubmitListenOpts struct {
ReleaseGroupMbzID uuid.UUID
Time time.Time
UserID int32
Client string
UserID int32
Client string
IsNowPlaying bool
}
const (
@ -165,6 +168,14 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
}
}
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
}
@ -190,21 +201,18 @@ func buildArtistStr(artists []*models.Artist) string {
var (
// Bracketed feat patterns
bracketFeatPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\(feat\. ([^)]*)\)`),
regexp.MustCompile(`(?i)\[feat\. ([^\]]*)\]`),
regexp.MustCompile(`(?i)\([fF]eat\. ([^)]*)\)`),
regexp.MustCompile(`(?i)\[[fF]eat\. ([^\]]*)\]`),
}
// Inline feat (not in brackets)
inlineFeatPattern = regexp.MustCompile(`(?i)feat\. ([^()\[\]]+)$`)
inlineFeatPattern = regexp.MustCompile(`(?i)[fF]eat\. ([^()\[\]]+)$`)
// Delimiters only used inside feat. sections
featSplitDelimiters = regexp.MustCompile(`(?i)\s*(?:,|&|and|·)\s*`)
// Delimiter for separating artists in main string (rare but real usage)
mainArtistDotSplitter = regexp.MustCompile(`\s+·\s+`)
)
// ParseArtists extracts all contributing artist names from the artist and title strings
func ParseArtists(artist string, title string) []string {
func ParseArtists(artist string, title string, addlSeparators []*regexp.Regexp) []string {
seen := make(map[string]struct{})
var out []string
@ -219,12 +227,9 @@ func ParseArtists(artist string, title string) []string {
}
}
foundFeat := false
// Extract bracketed features from artist
for _, re := range bracketFeatPatterns {
if matches := re.FindStringSubmatch(artist); matches != nil {
foundFeat = true
artist = strings.Replace(artist, matches[0], "", 1)
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
@ -233,7 +238,6 @@ func ParseArtists(artist string, title string) []string {
}
// Extract inline feat. from artist
if matches := inlineFeatPattern.FindStringSubmatch(artist); matches != nil {
foundFeat = true
artist = strings.Replace(artist, matches[0], "", 1)
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
@ -241,14 +245,19 @@ func ParseArtists(artist string, title string) []string {
}
// Add base artist(s)
if foundFeat {
add(strings.TrimSpace(artist))
} else {
// Only split on " · " in base artist string
for _, name := range mainArtistDotSplitter.Split(artist, -1) {
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 {

View file

@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"regexp"
"testing"
"time"
@ -167,15 +168,15 @@ func getTestGetenv(resource *dockertest.Resource) func(string) string {
func truncateTestData(t *testing.T) {
err := store.Exec(context.Background(),
`TRUNCATE
artists,
`TRUNCATE
artists,
artist_aliases,
tracks,
artist_tracks,
releases,
artist_releases,
tracks,
artist_tracks,
releases,
artist_releases,
release_aliases,
listens
listens
RESTART IDENTITY CASCADE`)
require.NoError(t, err)
}
@ -184,23 +185,23 @@ func setupTestDataWithMbzIDs(t *testing.T) {
truncateTestData(t)
err := store.Exec(context.Background(),
`INSERT INTO artists (musicbrainz_id)
`INSERT INTO artists (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000001')`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO releases (musicbrainz_id)
`INSERT INTO releases (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000101')`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'AG! Calling', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_releases (artist_id, release_id)
`INSERT INTO artist_releases (artist_id, release_id)
VALUES (1, 1)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
@ -221,23 +222,23 @@ func setupTestDataSansMbzIDs(t *testing.T) {
truncateTestData(t)
err := store.Exec(context.Background(),
`INSERT INTO artists (musicbrainz_id)
`INSERT INTO artists (musicbrainz_id)
VALUES (NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO releases (musicbrainz_id)
`INSERT INTO releases (musicbrainz_id)
VALUES (NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'AG! Calling', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_releases (artist_id, release_id)
`INSERT INTO artist_releases (artist_id, release_id)
VALUES (1, 1)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
@ -358,10 +359,16 @@ func TestArtistStringParse(t *testing.T) {
// artists in both
{"Daft Punk feat. Julian Casablancas", "Instant Crush (feat. Julian Casablancas)"}: {"Daft Punk", "Julian Casablancas"},
{"Paramore (feat. Joy Williams)", "Hate to See Your Heart Break feat. Joy Williams"}: {"Paramore", "Joy Williams"},
{"MINSU", "오해 금지 (Feat. BIG Naughty)"}: {"MINSU", "BIG Naughty"},
{"MINSU", "오해 금지 [Feat. BIG Naughty]"}: {"MINSU", "BIG Naughty"},
{"MINSU", "오해 금지 Feat. BIG Naughty"}: {"MINSU", "BIG Naughty"},
// custom separator
{"MIMiNARI//楠木ともり", "眠れない"}: {"MIMiNARI", "楠木ともり"},
}
for in, out := range cases {
artists := catalog.ParseArtists(in.Name, in.Title)
artists := catalog.ParseArtists(in.Name, in.Title, []*regexp.Regexp{regexp.MustCompile(`\s*//\s*`), regexp.MustCompile(`\s+·\s+`)})
assert.ElementsMatch(t, out, artists)
}
}

View file

@ -203,6 +203,22 @@ func TestSubmitListen_CreateAllNoMbzIDsNoArtistNamesNoReleaseTitle(t *testing.T)
)`, "Madeline Kenney")
require.NoError(t, err)
assert.True(t, exists, "expected featured artist to be created")
// assert that Rat Tally is the primary artist
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artist_tracks
WHERE artist_id = $1 AND is_primary = $2
)`, 1, true)
require.NoError(t, err)
assert.True(t, exists, "expected primary artist to be marked as primary for track")
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artist_releases
WHERE artist_id = $1 AND is_primary = $2
)`, 1, true)
require.NoError(t, err)
assert.True(t, exists, "expected primary artist to be marked as primary for release")
}
func TestSubmitListen_MatchAllMbzIDs(t *testing.T) {

View file

@ -3,6 +3,7 @@ package cfg
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
@ -31,9 +32,12 @@ const (
CONFIG_DIR_ENV = "KOITO_CONFIG_DIR"
DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME"
DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD"
DEFAULT_THEME_ENV = "KOITO_DEFAULT_THEME"
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
SUBSONIC_URL_ENV = "KOITO_SUBSONIC_URL"
SUBSONIC_PARAMS_ENV = "KOITO_SUBSONIC_PARAMS"
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
@ -42,6 +46,7 @@ const (
IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX"
IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX"
FETCH_IMAGES_DURING_IMPORT_ENV = "KOITO_FETCH_IMAGES_DURING_IMPORT"
ARTIST_SEPARATORS_ENV = "KOITO_ARTIST_SEPARATORS_REGEX"
)
type config struct {
@ -60,9 +65,13 @@ type config struct {
lbzRelayToken string
defaultPw string
defaultUsername string
defaultTheme string
disableDeezer bool
disableCAA bool
disableMusicBrainz bool
subsonicUrl string
subsonicParams string
subsonicEnabled bool
skipImport bool
fetchImageDuringImport bool
allowedHosts []string
@ -73,6 +82,7 @@ type config struct {
userAgent string
importBefore time.Time
importAfter time.Time
artistSeparators []*regexp.Regexp
}
var (
@ -147,6 +157,12 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV))
cfg.disableCAA = parseBool(getenv(DISABLE_COVER_ART_ARCHIVE_ENV))
cfg.disableMusicBrainz = parseBool(getenv(DISABLE_MUSICBRAINZ_ENV))
cfg.subsonicUrl = getenv(SUBSONIC_URL_ENV)
cfg.subsonicParams = getenv(SUBSONIC_PARAMS_ENV)
cfg.subsonicEnabled = cfg.subsonicUrl != "" && cfg.subsonicParams != ""
if cfg.subsonicEnabled && (cfg.subsonicUrl == "" || cfg.subsonicParams == "") {
return nil, fmt.Errorf("loadConfig: invalid configuration: both %s and %s must be set in order to use subsonic image fetching", SUBSONIC_URL_ENV, SUBSONIC_PARAMS_ENV)
}
cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV))
cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version)
@ -162,6 +178,8 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
cfg.defaultPw = getenv(DEFAULT_PASSWORD_ENV)
}
cfg.defaultTheme = getenv(DEFAULT_THEME_ENV)
cfg.configDir = getenv(CONFIG_DIR_ENV)
if cfg.configDir == "" {
cfg.configDir = "/etc/koito"
@ -174,6 +192,18 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
rawCors := getenv(CORS_ORIGINS_ENV)
cfg.allowedOrigins = strings.Split(rawCors, ",")
if getenv(ARTIST_SEPARATORS_ENV) != "" {
for pattern := range strings.SplitSeq(getenv(ARTIST_SEPARATORS_ENV), ";;") {
regex, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("failed to compile regex pattern %s", pattern)
}
cfg.artistSeparators = append(cfg.artistSeparators, regex)
}
} else {
cfg.artistSeparators = []*regexp.Regexp{regexp.MustCompile(`\s+·\s+`)}
}
switch strings.ToLower(getenv(LOG_LEVEL_ENV)) {
case "debug":
cfg.logLevel = 0
@ -277,6 +307,12 @@ func DefaultUsername() string {
return globalConfig.defaultUsername
}
func DefaultTheme() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.defaultTheme
}
func FullImageCacheEnabled() bool {
lock.RLock()
defer lock.RUnlock()
@ -301,6 +337,24 @@ func MusicBrainzDisabled() bool {
return globalConfig.disableMusicBrainz
}
func SubsonicEnabled() bool {
lock.RLock()
defer lock.RUnlock()
return globalConfig.subsonicEnabled
}
func SubsonicUrl() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.subsonicUrl
}
func SubsonicParams() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.subsonicParams
}
func SkipImport() bool {
lock.RLock()
defer lock.RUnlock()
@ -349,3 +403,9 @@ func FetchImagesDuringImport() bool {
defer lock.RUnlock()
return globalConfig.fetchImageDuringImport
}
func ArtistSeparators() []*regexp.Regexp {
lock.RLock()
defer lock.RUnlock()
return globalConfig.artistSeparators
}

View file

@ -98,8 +98,14 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
return nil, fmt.Errorf("GetAlbum: CountTimeListenedToItem: %w", err)
}
firstListen, err := d.q.GetFirstListenFromRelease(ctx, ret.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err)
}
ret.ListenCount = count
ret.TimeListened = seconds
ret.FirstListen = firstListen.ListenedAt.Unix()
return ret, nil
}
@ -144,6 +150,7 @@ func (d *Psql) SaveAlbum(ctx context.Context, opts db.SaveAlbumOpts) (*models.Al
err = qtx.AssociateArtistToRelease(ctx, repository.AssociateArtistToReleaseParams{
ArtistID: artistId,
ReleaseID: r.ID,
IsPrimary: opts.ArtistIDs[0] == artistId,
})
if err != nil {
return nil, fmt.Errorf("SaveAlbum: AssociateArtistToRelease: %w", err)

View file

@ -41,6 +41,10 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
}
firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err)
}
return &models.Artist{
ID: row.ID,
MbzID: row.MusicBrainzID,
@ -49,6 +53,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
Image: row.Image,
ListenCount: count,
TimeListened: seconds,
FirstListen: firstListen.ListenedAt.Unix(),
}, nil
} else if opts.MusicBrainzID != uuid.Nil {
l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID)
@ -71,14 +76,19 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
}
firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err)
}
return &models.Artist{
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
TimeListened: seconds,
ListenCount: count,
TimeListened: seconds,
FirstListen: firstListen.ListenedAt.Unix(),
}, nil
} else if opts.Name != "" {
l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name)
@ -101,6 +111,10 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil {
return nil, fmt.Errorf("GetArtist: CountTimeListenedToItem: %w", err)
}
firstListen, err := d.q.GetFirstListenFromArtist(ctx, row.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromArtist: %w", err)
}
return &models.Artist{
ID: row.ID,
MbzID: row.MusicBrainzID,
@ -109,6 +123,7 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
Image: row.Image,
ListenCount: count,
TimeListened: seconds,
FirstListen: firstListen.ListenedAt.Unix(),
}, nil
} else {
return nil, errors.New("insufficient information to get artist")

View file

@ -89,8 +89,14 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
return nil, fmt.Errorf("GetTrack: CountTimeListenedToItem: %w", err)
}
firstListen, err := d.q.GetFirstListenFromTrack(ctx, track.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("GetAlbum: GetFirstListenFromRelease: %w", err)
}
track.ListenCount = count
track.TimeListened = seconds
track.FirstListen = firstListen.ListenedAt.Unix()
return &track, nil
}
@ -132,8 +138,9 @@ func (d *Psql) SaveTrack(ctx context.Context, opts db.SaveTrackOpts) (*models.Tr
// insert associated artists
for _, aid := range opts.ArtistIDs {
err = qtx.AssociateArtistToTrack(ctx, repository.AssociateArtistToTrackParams{
ArtistID: aid,
TrackID: trackRow.ID,
ArtistID: aid,
TrackID: trackRow.ID,
IsPrimary: opts.ArtistIDs[0] == aid,
})
if err != nil {
return nil, fmt.Errorf("SaveTrack: AssociateArtistToTrack: %w", err)

View file

@ -12,14 +12,17 @@ import (
)
type ImageSource struct {
deezerEnabled bool
deezerC *DeezerClient
caaEnabled bool
deezerEnabled bool
deezerC *DeezerClient
subsonicEnabled bool
subsonicC *SubsonicClient
caaEnabled bool
}
type ImageSourceOpts struct {
UserAgent string
EnableCAA bool
EnableDeezer bool
UserAgent string
EnableCAA bool
EnableDeezer bool
EnableSubsonic bool
}
var once sync.Once
@ -48,6 +51,10 @@ func Initialize(opts ImageSourceOpts) {
imgsrc.deezerEnabled = true
imgsrc.deezerC = NewDeezerClient()
}
if opts.EnableSubsonic {
imgsrc.subsonicEnabled = true
imgsrc.subsonicC = NewSubsonicClient()
}
})
}
@ -57,6 +64,16 @@ func Shutdown() {
func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
l := logger.FromContext(ctx)
if imgsrc.subsonicEnabled {
img, err := imgsrc.subsonicC.GetArtistImage(ctx, opts.Aliases[0])
if err != nil {
return "", err
}
if img != "" {
return img, nil
}
l.Debug().Msg("Could not find artist image from Subsonic")
}
if imgsrc.deezerC != nil {
img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases)
if err != nil {
@ -69,6 +86,16 @@ func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
}
func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) {
l := logger.FromContext(ctx)
if imgsrc.subsonicEnabled {
img, err := imgsrc.subsonicC.GetAlbumImage(ctx, opts.Artists[0], opts.Album)
if err != nil {
return "", err
}
if img != "" {
return img, nil
}
l.Debug().Msg("Could not find album cover from Subsonic")
}
if imgsrc.caaEnabled {
l.Debug().Msg("Attempting to find album image from CoverArtArchive")
if opts.ReleaseMbzID != nil && *opts.ReleaseMbzID != uuid.Nil {

137
internal/images/subsonic.go Normal file
View file

@ -0,0 +1,137 @@
package images
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/queue"
)
type SubsonicClient struct {
url string
userAgent string
authParams string
requestQueue *queue.RequestQueue
}
type SubsonicAlbumResponse struct {
SubsonicResponse struct {
Status string `json:"status"`
SearchResult3 struct {
Album []struct {
CoverArt string `json:"coverArt"`
} `json:"album"`
} `json:"searchResult3"`
} `json:"subsonic-response"`
}
type SubsonicArtistResponse struct {
SubsonicResponse struct {
Status string `json:"status"`
SearchResult3 struct {
Artist []struct {
ArtistImageUrl string `json:"artistImageUrl"`
} `json:"artist"`
} `json:"searchResult3"`
} `json:"subsonic-response"`
}
const (
subsonicAlbumSearchFmtStr = "/rest/search3?%s&f=json&query=%s&v=1.13.0&c=koito&artistCount=0&songCount=0&albumCount=1"
subsonicArtistSearchFmtStr = "/rest/search3?%s&f=json&query=%s&v=1.13.0&c=koito&artistCount=1&songCount=0&albumCount=0"
subsonicCoverArtFmtStr = "/rest/getCoverArt?%s&id=%s&v=1.13.0&c=koito"
)
func NewSubsonicClient() *SubsonicClient {
ret := new(SubsonicClient)
ret.url = cfg.SubsonicUrl()
ret.userAgent = cfg.UserAgent()
ret.authParams = cfg.SubsonicParams()
ret.requestQueue = queue.NewRequestQueue(5, 5)
return ret
}
func (c *SubsonicClient) queue(ctx context.Context, req *http.Request) ([]byte, error) {
l := logger.FromContext(ctx)
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json")
resultChan := c.requestQueue.Enqueue(func(client *http.Client, done chan<- queue.RequestResult) {
resp, err := client.Do(req)
if err != nil {
l.Debug().Err(err).Str("url", req.RequestURI).Msg("Failed to contact ImageSrc")
done <- queue.RequestResult{Err: err}
return
} else if resp.StatusCode >= 300 || resp.StatusCode < 200 {
err = fmt.Errorf("recieved non-ok status from Subsonic: %s", resp.Status)
done <- queue.RequestResult{Body: nil, Err: err}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
done <- queue.RequestResult{Body: body, Err: err}
})
result := <-resultChan
return result.Body, result.Err
}
func (c *SubsonicClient) getEntity(ctx context.Context, endpoint string, result any) error {
l := logger.FromContext(ctx)
url := c.url + endpoint
l.Debug().Msgf("Sending request to ImageSrc: GET %s", url)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return fmt.Errorf("getEntity: %w", err)
}
l.Debug().Msg("Adding ImageSrc request to queue")
body, err := c.queue(ctx, req)
if err != nil {
l.Err(err).Msg("Subsonic request failed")
return fmt.Errorf("getEntity: %w", err)
}
err = json.Unmarshal(body, result)
if err != nil {
l.Err(err).Msg("Failed to unmarshal Subsonic response")
return fmt.Errorf("getEntity: %w", err)
}
return nil
}
func (c *SubsonicClient) GetAlbumImage(ctx context.Context, artist, album string) (string, error) {
l := logger.FromContext(ctx)
resp := new(SubsonicAlbumResponse)
l.Debug().Msgf("Finding album image for %s from artist %s", album, artist)
err := c.getEntity(ctx, fmt.Sprintf(subsonicAlbumSearchFmtStr, c.authParams, url.QueryEscape(artist+" "+album)), resp)
if err != nil {
return "", fmt.Errorf("GetAlbumImage: %v", err)
}
l.Debug().Any("subsonic_response", resp).Send()
if len(resp.SubsonicResponse.SearchResult3.Album) < 1 || resp.SubsonicResponse.SearchResult3.Album[0].CoverArt == "" {
return "", fmt.Errorf("GetAlbumImage: failed to get album art")
}
return cfg.SubsonicUrl() + fmt.Sprintf(subsonicCoverArtFmtStr, c.authParams, url.QueryEscape(resp.SubsonicResponse.SearchResult3.Album[0].CoverArt)), nil
}
func (c *SubsonicClient) GetArtistImage(ctx context.Context, artist string) (string, error) {
l := logger.FromContext(ctx)
resp := new(SubsonicArtistResponse)
l.Debug().Msgf("Finding artist image for %s", artist)
err := c.getEntity(ctx, fmt.Sprintf(subsonicArtistSearchFmtStr, c.authParams, url.QueryEscape(artist)), resp)
if err != nil {
return "", fmt.Errorf("GetArtistImage: %v", err)
}
l.Debug().Any("subsonic_response", resp).Send()
if len(resp.SubsonicResponse.SearchResult3.Artist) < 1 || resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl == "" {
return "", fmt.Errorf("GetArtistImage: failed to get artist art")
}
return resp.SubsonicResponse.SearchResult3.Artist[0].ArtistImageUrl, nil
}

110
internal/memkv/memkv.go Normal file
View file

@ -0,0 +1,110 @@
package memkv
import (
"sync"
"time"
)
type item struct {
value interface{}
expiresAt time.Time
}
type InMemoryStore struct {
data map[string]item
defaultExpiration time.Duration
mu sync.RWMutex
stopJanitor chan struct{}
}
var Store *InMemoryStore
func init() {
Store = NewStore(10 * time.Minute)
}
func NewStore(defaultExpiration time.Duration) *InMemoryStore {
s := &InMemoryStore{
data: make(map[string]item),
defaultExpiration: defaultExpiration,
stopJanitor: make(chan struct{}),
}
go s.janitor(1 * time.Minute)
return s
}
func (s *InMemoryStore) Set(key string, value interface{}, expiration ...time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
exp := s.defaultExpiration
if len(expiration) > 0 {
exp = expiration[0]
}
var expiresAt time.Time
if exp > 0 {
expiresAt = time.Now().Add(exp)
}
s.data[key] = item{
value: value,
expiresAt: expiresAt,
}
}
func (s *InMemoryStore) Get(key string) (interface{}, bool) {
s.mu.RLock()
it, found := s.data[key]
s.mu.RUnlock()
if !found {
return nil, false
}
if !it.expiresAt.IsZero() && time.Now().After(it.expiresAt) {
s.Delete(key)
return nil, false
}
return it.value, true
}
func (s *InMemoryStore) Delete(key string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.data, key)
}
func (s *InMemoryStore) janitor(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.cleanup()
case <-s.stopJanitor:
return
}
}
}
func (s *InMemoryStore) cleanup() {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
for k, it := range s.data {
if !it.expiresAt.IsZero() && now.After(it.expiresAt) {
delete(s.data, k)
}
}
}
func (s *InMemoryStore) Close() {
close(s.stopJanitor)
}

View file

@ -11,6 +11,7 @@ type Album struct {
VariousArtists bool `json:"is_various_artists"`
ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
}
// type SimpleAlbum struct {

View file

@ -10,6 +10,7 @@ type Artist struct {
Image *uuid.UUID `json:"image"`
ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
IsPrimary bool `json:"is_primary,omitempty"`
}
@ -27,5 +28,6 @@ type ArtistWithFullAliases struct {
ImageSource string `json:"image_source,omitempty"`
ListenCount int64 `json:"listen_count"`
TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
IsPrimary bool `json:"is_primary,omitempty"`
}

View file

@ -12,4 +12,5 @@ type Track struct {
Image *uuid.UUID `json:"image"`
AlbumID int32 `json:"album_id"`
TimeListened int64 `json:"time_listened"`
FirstListen int64 `json:"first_listen"`
}

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: alias.sql
package repository

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: artist.sql
package repository

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
package repository

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: etc.sql
package repository

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: listen.sql
package repository
@ -190,6 +190,73 @@ func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) erro
return err
}
const getFirstListenFromArtist = `-- name: GetFirstListenFromArtist :one
SELECT
l.track_id, l.listened_at, l.client, l.user_id
FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id
JOIN artist_tracks at ON t.id = at.track_id
WHERE at.artist_id = $1
ORDER BY l.listened_at ASC
LIMIT 1
`
func (q *Queries) GetFirstListenFromArtist(ctx context.Context, artistID int32) (Listen, error) {
row := q.db.QueryRow(ctx, getFirstListenFromArtist, artistID)
var i Listen
err := row.Scan(
&i.TrackID,
&i.ListenedAt,
&i.Client,
&i.UserID,
)
return i, err
}
const getFirstListenFromRelease = `-- name: GetFirstListenFromRelease :one
SELECT
l.track_id, l.listened_at, l.client, l.user_id
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.release_id = $1
ORDER BY l.listened_at ASC
LIMIT 1
`
func (q *Queries) GetFirstListenFromRelease(ctx context.Context, releaseID int32) (Listen, error) {
row := q.db.QueryRow(ctx, getFirstListenFromRelease, releaseID)
var i Listen
err := row.Scan(
&i.TrackID,
&i.ListenedAt,
&i.Client,
&i.UserID,
)
return i, err
}
const getFirstListenFromTrack = `-- name: GetFirstListenFromTrack :one
SELECT
l.track_id, l.listened_at, l.client, l.user_id
FROM listens l
JOIN tracks t ON l.track_id = t.id
WHERE t.id = $1
ORDER BY l.listened_at ASC
LIMIT 1
`
func (q *Queries) GetFirstListenFromTrack(ctx context.Context, id int32) (Listen, error) {
row := q.db.QueryRow(ctx, getFirstListenFromTrack, id)
var i Listen
err := row.Scan(
&i.TrackID,
&i.ListenedAt,
&i.Client,
&i.UserID,
)
return i, err
}
const getLastListensFromArtistPaginated = `-- name: GetLastListensFromArtistPaginated :many
SELECT
l.track_id, l.listened_at, l.client, l.user_id,

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
package repository

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: release.sql
package repository
@ -14,18 +14,19 @@ import (
)
const associateArtistToRelease = `-- name: AssociateArtistToRelease :exec
INSERT INTO artist_releases (artist_id, release_id)
VALUES ($1, $2)
INSERT INTO artist_releases (artist_id, release_id, is_primary)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
`
type AssociateArtistToReleaseParams struct {
ArtistID int32
ReleaseID int32
IsPrimary bool
}
func (q *Queries) AssociateArtistToRelease(ctx context.Context, arg AssociateArtistToReleaseParams) error {
_, err := q.db.Exec(ctx, associateArtistToRelease, arg.ArtistID, arg.ReleaseID)
_, err := q.db.Exec(ctx, associateArtistToRelease, arg.ArtistID, arg.ReleaseID, arg.IsPrimary)
return err
}

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: search.sql
package repository

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: sessions.sql
package repository

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: track.sql
package repository
@ -13,18 +13,19 @@ import (
)
const associateArtistToTrack = `-- name: AssociateArtistToTrack :exec
INSERT INTO artist_tracks (artist_id, track_id)
VALUES ($1, $2)
INSERT INTO artist_tracks (artist_id, track_id, is_primary)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
`
type AssociateArtistToTrackParams struct {
ArtistID int32
TrackID int32
ArtistID int32
TrackID int32
IsPrimary bool
}
func (q *Queries) AssociateArtistToTrack(ctx context.Context, arg AssociateArtistToTrackParams) error {
_, err := q.db.Exec(ctx, associateArtistToTrack, arg.ArtistID, arg.TrackID)
_, err := q.db.Exec(ctx, associateArtistToTrack, arg.ArtistID, arg.TrackID, arg.IsPrimary)
return err
}

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: users.sql
package repository

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.30.0
// source: year.sql
package repository