mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 13:38:15 -08:00
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:
parent
bf0ec68cfe
commit
36f984a1a2
56 changed files with 1887 additions and 906 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
137
internal/images/subsonic.go
Normal 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
110
internal/memkv/memkv.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// sqlc v1.30.0
|
||||
|
||||
package repository
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// sqlc v1.30.0
|
||||
|
||||
package repository
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue