add multi-artist support for comma separated artists

This commit is contained in:
2026-03-01 17:18:19 -08:00
parent d73ae51b95
commit 369aae818c
14 changed files with 281 additions and 40 deletions

View File

@@ -250,8 +250,10 @@ func AddHistoryEntityColumns() error {
_, err := Pool.Exec(context.Background(), _, err := Pool.Exec(context.Background(),
`ALTER TABLE history ADD COLUMN IF NOT EXISTS artist_id INTEGER REFERENCES artists(id) ON DELETE SET NULL; `ALTER TABLE history ADD COLUMN IF NOT EXISTS artist_id INTEGER REFERENCES artists(id) ON DELETE SET NULL;
ALTER TABLE history ADD COLUMN IF NOT EXISTS song_id INTEGER REFERENCES songs(id) ON DELETE SET NULL; ALTER TABLE history ADD COLUMN IF NOT EXISTS song_id INTEGER REFERENCES songs(id) ON DELETE SET NULL;
ALTER TABLE history ADD COLUMN IF NOT EXISTS artist_ids INTEGER[] DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_history_artist_id ON history(artist_id); CREATE INDEX IF NOT EXISTS idx_history_artist_id ON history(artist_id);
CREATE INDEX IF NOT EXISTS idx_history_song_id ON history(song_id);`) CREATE INDEX IF NOT EXISTS idx_history_song_id ON history(song_id);
CREATE INDEX IF NOT EXISTS idx_history_artist_ids ON history USING gin(artist_ids);`)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error adding history entity columns: %v\n", err) fmt.Fprintf(os.Stderr, "Error adding history entity columns: %v\n", err)
return err return err

View File

@@ -499,7 +499,7 @@ func SearchSongs(userId int, query string) ([]Song, float64, error) {
func GetArtistStats(userId, artistId int) (int, error) { func GetArtistStats(userId, artistId int) (int, error) {
var count int var count int
err := Pool.QueryRow(context.Background(), err := Pool.QueryRow(context.Background(),
"SELECT COUNT(*) FROM history WHERE user_id = $1 AND artist_id = $2", "SELECT COUNT(*) FROM history WHERE user_id = $1 AND $2 = ANY(artist_ids)",
userId, artistId).Scan(&count) userId, artistId).Scan(&count)
return count, err return count, err
} }
@@ -534,8 +534,9 @@ func MergeArtists(userId int, fromArtistId, toArtistId int) error {
func GetHistoryForArtist(userId, artistId int, limit, offset int) ([]ScrobbleEntry, error) { func GetHistoryForArtist(userId, artistId int, limit, offset int) ([]ScrobbleEntry, error) {
rows, err := Pool.Query(context.Background(), rows, err := Pool.Query(context.Background(),
`SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform, `SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform,
(SELECT name FROM artists WHERE id = h.artist_id) as artist_name (SELECT name FROM artists WHERE id = h.artist_id) as artist_name,
FROM history h WHERE h.user_id = $1 AND h.artist_id = $2 h.artist_ids
FROM history h WHERE h.user_id = $1 AND $2 = ANY(h.artist_ids)
ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`, ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`,
userId, artistId, limit, offset) userId, artistId, limit, offset)
if err != nil { if err != nil {
@@ -546,7 +547,7 @@ func GetHistoryForArtist(userId, artistId int, limit, offset int) ([]ScrobbleEnt
var entries []ScrobbleEntry var entries []ScrobbleEntry
for rows.Next() { for rows.Next() {
var e ScrobbleEntry var e ScrobbleEntry
err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName) err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName, &e.ArtistIds)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -558,7 +559,8 @@ func GetHistoryForArtist(userId, artistId int, limit, offset int) ([]ScrobbleEnt
func GetHistoryForSong(userId, songId int, limit, offset int) ([]ScrobbleEntry, error) { func GetHistoryForSong(userId, songId int, limit, offset int) ([]ScrobbleEntry, error) {
rows, err := Pool.Query(context.Background(), rows, err := Pool.Query(context.Background(),
`SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform, `SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform,
(SELECT name FROM artists WHERE id = h.artist_id) as artist_name (SELECT name FROM artists WHERE id = h.artist_id) as artist_name,
h.artist_ids
FROM history h WHERE h.user_id = $1 AND h.song_id = $2 FROM history h WHERE h.user_id = $1 AND h.song_id = $2
ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`, ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`,
userId, songId, limit, offset) userId, songId, limit, offset)
@@ -570,7 +572,7 @@ func GetHistoryForSong(userId, songId int, limit, offset int) ([]ScrobbleEntry,
var entries []ScrobbleEntry var entries []ScrobbleEntry
for rows.Next() { for rows.Next() {
var e ScrobbleEntry var e ScrobbleEntry
err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName) err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName, &e.ArtistIds)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -586,6 +588,7 @@ type ScrobbleEntry struct {
AlbumName string AlbumName string
MsPlayed int MsPlayed int
Platform string Platform string
ArtistIds []int
} }
func MigrateHistoryEntities() error { func MigrateHistoryEntities() error {
@@ -703,7 +706,8 @@ func GetAlbumStats(userId, albumId int) (int, error) {
func GetHistoryForAlbum(userId, albumId int, limit, offset int) ([]ScrobbleEntry, error) { func GetHistoryForAlbum(userId, albumId int, limit, offset int) ([]ScrobbleEntry, error) {
rows, err := Pool.Query(context.Background(), rows, err := Pool.Query(context.Background(),
`SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform, `SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform,
(SELECT name FROM artists WHERE id = h.artist_id) as artist_name (SELECT name FROM artists WHERE id = h.artist_id) as artist_name,
h.artist_ids
FROM history h FROM history h
JOIN songs s ON h.song_id = s.id JOIN songs s ON h.song_id = s.id
WHERE h.user_id = $1 AND s.album_id = $2 WHERE h.user_id = $1 AND s.album_id = $2
@@ -717,7 +721,7 @@ func GetHistoryForAlbum(userId, albumId int, limit, offset int) ([]ScrobbleEntry
var entries []ScrobbleEntry var entries []ScrobbleEntry
for rows.Next() { for rows.Next() {
var e ScrobbleEntry var e ScrobbleEntry
err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName) err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName, &e.ArtistIds)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -743,7 +747,8 @@ func GetHistoryForSongs(userId int, songIds []int, limit, offset int) ([]Scrobbl
} }
rows, err := Pool.Query(context.Background(), rows, err := Pool.Query(context.Background(),
`SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform, `SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform,
(SELECT name FROM artists WHERE id = h.artist_id) as artist_name (SELECT name FROM artists WHERE id = h.artist_id) as artist_name,
h.artist_ids
FROM history h WHERE h.user_id = $1 AND h.song_id = ANY($2) FROM history h WHERE h.user_id = $1 AND h.song_id = ANY($2)
ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`, ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`,
userId, songIds, limit, offset) userId, songIds, limit, offset)
@@ -755,7 +760,7 @@ func GetHistoryForSongs(userId int, songIds []int, limit, offset int) ([]Scrobbl
var entries []ScrobbleEntry var entries []ScrobbleEntry
for rows.Next() { for rows.Next() {
var e ScrobbleEntry var e ScrobbleEntry
err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName) err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName, &e.ArtistIds)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -236,20 +236,65 @@ func ImportLastFM(
} }
func insertBatch(tracks []LastFMTrack, totalImported *int) error { func insertBatch(tracks []LastFMTrack, totalImported *int) error {
if len(tracks) == 0 {
return nil
}
artistIdMap, err := resolveLastFMArtistIds(tracks)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving artist IDs: %v\n", err)
return err
}
rows := make([][]any, 0, len(tracks))
for _, t := range tracks {
artistNames := parseArtistString(t.Artist)
var artistIds []int
for _, name := range artistNames {
if ids, ok := artistIdMap[name]; ok {
artistIds = append(artistIds, ids...)
}
}
primaryArtistId := 0
if len(artistIds) > 0 {
primaryArtistId = artistIds[0]
}
rows = append(rows, []any{
t.UserId, t.Timestamp, t.SongName, t.Artist,
t.Album, 0, "lastfm", primaryArtistId, artistIds,
})
}
copyCount, err := db.Pool.CopyFrom(context.Background(), copyCount, err := db.Pool.CopyFrom(context.Background(),
pgx.Identifier{"history"}, pgx.Identifier{"history"},
[]string{ []string{
"user_id", "timestamp", "song_name", "artist", "album_name", "user_id", "timestamp", "song_name", "artist", "album_name",
"ms_played", "platform", "ms_played", "platform", "artist_id", "artist_ids",
}, },
pgx.CopyFromSlice(len(tracks), func(i int) ([]any, error) { pgx.CopyFromRows(rows),
t := tracks[i]
return []any{
t.UserId, t.Timestamp, t.SongName, t.Artist,
t.Album, 0, "lastfm",
}, nil
}),
) )
*totalImported += int(copyCount) *totalImported += int(copyCount)
return err return err
} }
func resolveLastFMArtistIds(tracks []LastFMTrack) (map[string][]int, error) {
artistIdMap := make(map[string][]int)
for _, t := range tracks {
artistNames := parseArtistString(t.Artist)
for _, name := range artistNames {
if _, exists := artistIdMap[name]; !exists {
artistId, _, err := db.GetOrCreateArtist(t.UserId, name)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating artist %s: %v\n", name, err)
continue
}
artistIdMap[name] = []int{artistId}
}
}
}
return artistIdMap, nil
}

View File

@@ -43,6 +43,7 @@ type trackSource struct {
tracksToSkip map[string]struct{} // Set of duplicate keys to skip tracksToSkip map[string]struct{} // Set of duplicate keys to skip
idx int // Current position in tracks slice idx int // Current position in tracks slice
userId int // User ID to associate with imported tracks userId int // User ID to associate with imported tracks
artistIdMap map[string][]int // Map of track key to artist IDs
} }
// Represents a track already stored in the database, used for duplicate // Represents a track already stored in the database, used for duplicate
@@ -107,11 +108,19 @@ func ImportSpotify(tracks []SpotifyTrack,
continue continue
} }
artistIdMap, err := resolveArtistIds(userId, validTracks)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving artist IDs: %v\n", err)
batchStart += batchSize
continue
}
src := &trackSource{ src := &trackSource{
tracks: validTracks, tracks: validTracks,
tracksToSkip: tracksToSkip, tracksToSkip: tracksToSkip,
idx: 0, idx: 0,
userId: userId, userId: userId,
artistIdMap: artistIdMap,
} }
copyCount, err := db.Pool.CopyFrom( copyCount, err := db.Pool.CopyFrom(
@@ -125,6 +134,8 @@ func ImportSpotify(tracks []SpotifyTrack,
"album_name", "album_name",
"ms_played", "ms_played",
"platform", "platform",
"artist_id",
"artist_ids",
}, },
src, src,
) )
@@ -218,6 +229,43 @@ func getDupes(userId int, tracks []SpotifyTrack) (map[string]struct{}, error) {
return duplicates, nil return duplicates, nil
} }
func resolveArtistIds(userId int, tracks []SpotifyTrack) (map[string][]int, error) {
artistIdMap := make(map[string][]int)
for _, track := range tracks {
trackKey := createTrackKey(track)
artistNames := parseArtistString(track.Artist)
var artistIds []int
for _, name := range artistNames {
artistId, _, err := db.GetOrCreateArtist(userId, name)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating artist %s: %v\n", name, err)
continue
}
artistIds = append(artistIds, artistId)
}
artistIdMap[trackKey] = artistIds
}
return artistIdMap, nil
}
func parseArtistString(artist string) []string {
if artist == "" {
return nil
}
var artists []string
for _, a := range strings.Split(artist, ",") {
a = strings.TrimSpace(a)
if a != "" {
artists = append(artists, a)
}
}
return artists
}
// Get the min/max timestamp range for a batch of tracks // Get the min/max timestamp range for a batch of tracks
func findTimeRange(tracks []SpotifyTrack) (time.Time, time.Time) { func findTimeRange(tracks []SpotifyTrack) (time.Time, time.Time) {
var minTs, maxTs time.Time var minTs, maxTs time.Time
@@ -319,6 +367,14 @@ func (s *trackSource) Next() bool {
func (s *trackSource) Values() ([]any, error) { func (s *trackSource) Values() ([]any, error) {
// idx is already incremented in Next(), so use idx-1 // idx is already incremented in Next(), so use idx-1
t := s.tracks[s.idx-1] t := s.tracks[s.idx-1]
trackKey := createTrackKey(t)
artistIds := s.artistIdMap[trackKey]
primaryArtistId := 0
if len(artistIds) > 0 {
primaryArtistId = artistIds[0]
}
return []any{ return []any{
s.userId, s.userId,
t.Timestamp, t.Timestamp,
@@ -327,6 +383,8 @@ func (s *trackSource) Values() ([]any, error) {
t.Album, t.Album,
t.Played, t.Played,
"spotify", "spotify",
primaryArtistId,
artistIds,
}, nil }, nil
} }

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"os" "os"
"strings"
"time" "time"
"muzi/db" "muzi/db"
@@ -118,33 +119,38 @@ func SaveScrobble(scrobble Scrobble) error {
return fmt.Errorf("duplicate scrobble") return fmt.Errorf("duplicate scrobble")
} }
artistId, _, err := db.GetOrCreateArtist(scrobble.UserId, scrobble.Artist) artistNames := parseArtistString(scrobble.Artist)
artistIds, err := getOrCreateArtists(scrobble.UserId, artistNames)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error getting/creating artist: %v\n", err)
return err return err
} }
primaryArtistId := 0
if len(artistIds) > 0 {
primaryArtistId = artistIds[0]
}
var albumId int var albumId int
if scrobble.Album != "" { if scrobble.Album != "" {
albumId, _, err = db.GetOrCreateAlbum(scrobble.UserId, scrobble.Album, artistId) albumId, _, err = db.GetOrCreateAlbum(scrobble.UserId, scrobble.Album, primaryArtistId)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error getting/creating album: %v\n", err) fmt.Fprintf(os.Stderr, "Error getting/creating album: %v\n", err)
return err return err
} }
} }
songId, _, err := db.GetOrCreateSong(scrobble.UserId, scrobble.SongName, artistId, albumId) songId, _, err := db.GetOrCreateSong(scrobble.UserId, scrobble.SongName, primaryArtistId, albumId)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error getting/creating song: %v\n", err) fmt.Fprintf(os.Stderr, "Error getting/creating song: %v\n", err)
return err return err
} }
_, err = db.Pool.Exec(context.Background(), _, err = db.Pool.Exec(context.Background(),
`INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform, artist_id, song_id) `INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform, artist_id, song_id, artist_ids)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (user_id, song_name, artist, timestamp) DO NOTHING`, ON CONFLICT (user_id, song_name, artist, timestamp) DO NOTHING`,
scrobble.UserId, scrobble.Timestamp, scrobble.SongName, scrobble.Artist, scrobble.UserId, scrobble.Timestamp, scrobble.SongName, scrobble.Artist,
scrobble.Album, scrobble.MsPlayed, scrobble.Platform, artistId, songId) scrobble.Album, scrobble.MsPlayed, scrobble.Platform, primaryArtistId, songId, artistIds)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error saving scrobble: %v\n", err) fmt.Fprintf(os.Stderr, "Error saving scrobble: %v\n", err)
return err return err
@@ -152,6 +158,33 @@ func SaveScrobble(scrobble Scrobble) error {
return nil return nil
} }
func parseArtistString(artist string) []string {
if artist == "" {
return nil
}
var artists []string
for _, a := range strings.Split(artist, ",") {
a = strings.TrimSpace(a)
if a != "" {
artists = append(artists, a)
}
}
return artists
}
func getOrCreateArtists(userId int, artistNames []string) ([]int, error) {
var artistIds []int
for _, name := range artistNames {
id, _, err := db.GetOrCreateArtist(userId, name)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting/creating artist: %v\n", err)
return nil, err
}
artistIds = append(artistIds, id)
}
return artistIds, nil
}
func SaveScrobbles(scrobbles []Scrobble) (int, int, error) { func SaveScrobbles(scrobbles []Scrobble) (int, int, error) {
if len(scrobbles) == 0 { if len(scrobbles) == 0 {
return 0, 0, nil return 0, 0, nil

View File

@@ -334,6 +334,7 @@
justify-content: center; justify-content: center;
margin: 5px; margin: 5px;
width: 30%; width: 30%;
white-space: pre-wrap;
} }
tr:nth-child(even) { tr:nth-child(even) {
background-color: #111; background-color: #111;
@@ -341,6 +342,7 @@
a { a {
color: #AFA; color: #AFA;
text-decoration: none; text-decoration: none;
white-space: pre-wrap;
} }
a:hover { a:hover {
color: #FFF; color: #FFF;

View File

@@ -12,8 +12,10 @@
<button class="edit-btn" onclick="openEditModal()">Edit</button> <button class="edit-btn" onclick="openEditModal()">Edit</button>
{{end}} {{end}}
</h1> </h1>
{{if .Artist.Name}} {{if .ArtistNames}}
<h2><a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}">{{.Artist.Name}}</a></h2> <h2>
{{- range $i, $name := .ArtistNames}}{{if $i}}, {{end}}<a href="/profile/{{$.Username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
</h2>
{{end}} {{end}}
</div> </div>
<div class="profile-top-blank"> <div class="profile-top-blank">
@@ -34,7 +36,10 @@
{{$username := .Username}} {{$username := .Username}}
{{range .Times}} {{range .Times}}
<tr> <tr>
<td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td> <td>
{{- $artistNames := getArtistNames .ArtistIds}}
{{- range $i, $name := $artistNames}}{{if $i}}, {{end}}<a href="/profile/{{$username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
</td>
<td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td> <td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td>
<td>{{.AlbumName}}</td> <td>{{.AlbumName}}</td>
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td> <td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>

View File

@@ -31,7 +31,10 @@
{{$username := .Username}} {{$username := .Username}}
{{range .Times}} {{range .Times}}
<tr> <tr>
<td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td> <td>
{{- $artistNames := getArtistNames .ArtistIds}}
{{- range $i, $name := $artistNames}}{{if $i}}, {{end}}<a href="/profile/{{$username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
</td>
<td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td> <td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td>
<td><a href="/profile/{{$username}}/album/{{urlquery .ArtistName}}/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td> <td><a href="/profile/{{$username}}/album/{{urlquery .ArtistName}}/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td>
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td> <td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>

View File

@@ -28,13 +28,17 @@
<td>Now Playing</td> <td>Now Playing</td>
</tr> </tr>
{{end}} {{end}}
{{$artists := .Artists}} {{$artistIdsList := .ArtistIdsList}}
{{$times := .Times}} {{$times := .Times}}
{{$username := .Username}} {{$username := .Username}}
{{range $index, $title := .Titles}} {{range $index, $title := .Titles}}
<tr> <tr>
<td><a href="/profile/{{$username}}/artist/{{urlquery (index $artists $index)}}">{{index $artists $index}}</a></td> <td>
<td><a href="/profile/{{$username}}/song/{{urlquery (index $artists $index)}}/{{urlquery $title}}">{{$title}}</a></td> {{- $artistIds := index $artistIdsList $index}}
{{- $artistNames := getArtistNames $artistIds}}
{{- range $i, $name := $artistNames}}{{if $i}}, {{end}}<a href="/profile/{{$username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
</td>
<td><a href="/profile/{{$username}}/song/{{urlquery (index $.Artists $index)}}/{{urlquery $title}}">{{$title}}</a></td>
<td title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</td> <td title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</td>
</tr> </tr>
{{end}} {{end}}

View File

@@ -7,8 +7,10 @@
<button class="edit-btn" onclick="openEditModal()">Edit</button> <button class="edit-btn" onclick="openEditModal()">Edit</button>
{{end}} {{end}}
</h1> </h1>
{{if .Artist.Name}} {{if .ArtistNames}}
<h2><a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}">{{.Artist.Name}}</a></h2> <h2>
{{- range $i, $name := .ArtistNames}}{{if $i}}, {{end}}<a href="/profile/{{$.Username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
</h2>
{{end}} {{end}}
{{range .Albums}} {{range .Albums}}
<h3><a href="/profile/{{$.Username}}/album/{{urlquery $.Artist.Name}}/{{urlquery .Title}}">{{.Title}}</a></h3> <h3><a href="/profile/{{$.Username}}/album/{{urlquery $.Artist.Name}}/{{urlquery .Title}}">{{.Title}}</a></h3>
@@ -32,7 +34,10 @@
{{$username := .Username}} {{$username := .Username}}
{{range .Times}} {{range .Times}}
<tr> <tr>
<td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td> <td>
{{- $artistNames := getArtistNames .ArtistIds}}
{{- range $i, $name := $artistNames}}{{if $i}}, {{end}}<a href="/profile/{{$username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
</td>
<td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td> <td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td>
<td><a href="/profile/{{$username}}/album/{{urlquery .ArtistName}}/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td> <td><a href="/profile/{{$username}}/album/{{urlquery .ArtistName}}/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td>
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td> <td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>

View File

@@ -1,6 +1,7 @@
package web package web
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@@ -35,6 +36,7 @@ type SongData struct {
Username string Username string
Song db.Song Song db.Song
Artist db.Artist Artist db.Artist
ArtistNames []string
Albums []db.Album Albums []db.Album
ListenCount int ListenCount int
Times []db.ScrobbleEntry Times []db.ScrobbleEntry
@@ -48,6 +50,7 @@ type AlbumData struct {
Username string Username string
Album db.Album Album db.Album
Artist db.Artist Artist db.Artist
ArtistNames []string
ListenCount int ListenCount int
Times []db.ScrobbleEntry Times []db.ScrobbleEntry
Page int Page int
@@ -169,8 +172,14 @@ func songPageHandler() http.HandlerFunc {
var songIds []int var songIds []int
var albums []db.Album var albums []db.Album
seenAlbums := make(map[int]bool) seenAlbums := make(map[int]bool)
seenArtistIds := make(map[int]bool)
var allArtistIds []int
for _, s := range songs { for _, s := range songs {
songIds = append(songIds, s.Id) songIds = append(songIds, s.Id)
if s.ArtistId > 0 && !seenArtistIds[s.ArtistId] {
seenArtistIds[s.ArtistId] = true
allArtistIds = append(allArtistIds, s.ArtistId)
}
if s.AlbumId > 0 && !seenAlbums[s.AlbumId] { if s.AlbumId > 0 && !seenAlbums[s.AlbumId] {
seenAlbums[s.AlbumId] = true seenAlbums[s.AlbumId] = true
album, _ := db.GetAlbumById(s.AlbumId) album, _ := db.GetAlbumById(s.AlbumId)
@@ -178,6 +187,29 @@ func songPageHandler() http.HandlerFunc {
} }
} }
var artistNames []string
seenArtistIdsMap := make(map[int]bool)
rows, err := db.Pool.Query(context.Background(),
`SELECT DISTINCT artist_ids FROM history WHERE song_id = ANY($1)`,
songIds)
if err == nil {
defer rows.Close()
for rows.Next() {
var artistIds []int
if err := rows.Scan(&artistIds); err == nil {
for _, id := range artistIds {
if !seenArtistIdsMap[id] {
seenArtistIdsMap[id] = true
a, err := db.GetArtistById(id)
if err == nil {
artistNames = append(artistNames, a.Name)
}
}
}
}
}
}
pageStr := r.URL.Query().Get("page") pageStr := r.URL.Query().Get("page")
var pageInt int var pageInt int
if pageStr == "" { if pageStr == "" {
@@ -208,6 +240,7 @@ func songPageHandler() http.HandlerFunc {
Username: username, Username: username,
Song: song, Song: song,
Artist: artist, Artist: artist,
ArtistNames: artistNames,
Albums: albums, Albums: albums,
ListenCount: listenCount, ListenCount: listenCount,
Times: entries, Times: entries,
@@ -375,10 +408,25 @@ func albumPageHandler() http.HandlerFunc {
return return
} }
var artistNames []string
seenArtistIds := make(map[int]bool)
for _, e := range entries {
for _, artistId := range e.ArtistIds {
if !seenArtistIds[artistId] {
seenArtistIds[artistId] = true
a, err := db.GetArtistById(artistId)
if err == nil {
artistNames = append(artistNames, a.Name)
}
}
}
}
albumData := AlbumData{ albumData := AlbumData{
Username: username, Username: username,
Album: album, Album: album,
Artist: artist, Artist: artist,
ArtistNames: artistNames,
ListenCount: listenCount, ListenCount: listenCount,
Times: entries, Times: entries,
Page: pageInt, Page: pageInt,

View File

@@ -25,6 +25,7 @@ type ProfileData struct {
TrackCount int TrackCount int
ArtistCount int ArtistCount int
Artists []string Artists []string
ArtistIdsList [][]int
Titles []string Titles []string
Times []time.Time Times []time.Time
Page int Page int
@@ -93,7 +94,7 @@ func profilePageHandler() http.HandlerFunc {
rows, err := db.Pool.Query( rows, err := db.Pool.Query(
r.Context(), r.Context(),
"SELECT artist, song_name, timestamp FROM history WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;", "SELECT artist_id, song_name, timestamp, artist_ids FROM history WHERE user_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3;",
userId, userId,
lim, lim,
off, off,
@@ -106,15 +107,27 @@ func profilePageHandler() http.HandlerFunc {
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var artist, title string var artistId int
var title string
var time pgtype.Timestamptz var time pgtype.Timestamptz
err = rows.Scan(&artist, &title, &time) var artistIds []int
err = rows.Scan(&artistId, &title, &time, &artistIds)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Scanning history row failed: %v\n", err) fmt.Fprintf(os.Stderr, "Scanning history row failed: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
profileData.Artists = append(profileData.Artists, artist)
var artistName string
if artistId > 0 {
artist, err := db.GetArtistById(artistId)
if err == nil {
artistName = artist.Name
}
}
profileData.Artists = append(profileData.Artists, artistName)
profileData.ArtistIdsList = append(profileData.ArtistIdsList, artistIds)
profileData.Titles = append(profileData.Titles, title) profileData.Titles = append(profileData.Titles, title)
profileData.Times = append(profileData.Times, time.Time) profileData.Times = append(profileData.Times, time.Time)
} }

View File

@@ -5,6 +5,8 @@ package web
import ( import (
"fmt" "fmt"
"time" "time"
"muzi/db"
) )
// Subtracts two integers // Subtracts two integers
@@ -56,3 +58,18 @@ func formatTimestamp(timestamp time.Time) string {
func formatTimestampFull(timestamp time.Time) string { func formatTimestampFull(timestamp time.Time) string {
return timestamp.Format("Monday 2 Jan 2006, 3:04pm") return timestamp.Format("Monday 2 Jan 2006, 3:04pm")
} }
// GetArtistNames takes artist IDs and returns a slice of artist names
func GetArtistNames(artistIds []int) []string {
if artistIds == nil {
return nil
}
var names []string
for _, id := range artistIds {
artist, err := db.GetArtistById(id)
if err == nil {
names = append(names, artist.Name)
}
}
return names
}

View File

@@ -37,6 +37,7 @@ func init() {
"formatTimestamp": formatTimestamp, "formatTimestamp": formatTimestamp,
"formatTimestampFull": formatTimestampFull, "formatTimestampFull": formatTimestampFull,
"urlquery": url.QueryEscape, "urlquery": url.QueryEscape,
"getArtistNames": GetArtistNames,
} }
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml")) templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
} }