Compare commits

..

6 Commits

Author SHA1 Message Date
riwiwa
24fb1331b4 Update README
mark multi artist scrobbling as complete
2026-03-01 17:19:29 -08:00
369aae818c add multi-artist support for comma separated artists 2026-03-01 17:18:19 -08:00
d73ae51b95 fix artist images being squished on smaller width 2026-03-01 15:42:03 -08:00
1b6ff0c283 add unique track count for profiles 2026-03-01 15:41:34 -08:00
582d3acbc0 allow for 1 char searching 2026-03-01 15:28:48 -08:00
7d70d9ea0f fix same tracks in different album not appearing in track page 2026-03-01 15:14:13 -08:00
16 changed files with 478 additions and 72 deletions

View File

@@ -17,6 +17,6 @@
- Ability to specify a certain point in time from one datetime to another to list data
- Grid maker (3x3-10x10)
- Ability to change artist and album images \[Complete\]
- Multi artist scrobbling
- Multi artist scrobbling \[Complete\]
- Live scrobbling to the server (With Now playing status) \[Complete\]
- Batch scrobble editor

View File

@@ -250,8 +250,10 @@ func AddHistoryEntityColumns() error {
_, 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 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_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 {
fmt.Fprintf(os.Stderr, "Error adding history entity columns: %v\n", err)
return err

View File

@@ -394,6 +394,58 @@ func GetSongByName(userId int, title string, artistId int) (Song, error) {
return song, nil
}
func GetSongsByName(userId int, title string, artistId int) ([]Song, error) {
var query string
var args []interface{}
if artistId > 0 {
query = `SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id
FROM songs WHERE user_id = $1 AND title = $2 AND artist_id = $3 ORDER BY id`
args = []interface{}{userId, title, artistId}
} else {
query = `SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id
FROM songs WHERE user_id = $1 AND title = $2 ORDER BY id`
args = []interface{}{userId, title}
}
rows, err := Pool.Query(context.Background(), query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var songs []Song
for rows.Next() {
var song Song
var artistIdVal, albumIdVal pgtype.Int4
var durationMs *int
var spotifyIdPg, musicbrainzIdPg pgtype.Text
err := rows.Scan(
&song.Id, &song.UserId, &song.Title, &artistIdVal, &albumIdVal,
&durationMs, &spotifyIdPg, &musicbrainzIdPg)
if err != nil {
return nil, err
}
if artistIdVal.Status == pgtype.Present {
song.ArtistId = int(artistIdVal.Int)
}
if albumIdVal.Status == pgtype.Present {
song.AlbumId = int(albumIdVal.Int)
}
if durationMs != nil {
song.DurationMs = *durationMs
}
if spotifyIdPg.Status == pgtype.Present {
song.SpotifyId = spotifyIdPg.String
}
if musicbrainzIdPg.Status == pgtype.Present {
song.MusicbrainzId = musicbrainzIdPg.String
}
songs = append(songs, song)
}
return songs, nil
}
func UpdateSong(id int, title string, durationMs int, spotifyId, musicbrainzId string) error {
_, err := Pool.Exec(context.Background(),
`UPDATE songs SET title = $1, duration_ms = $2, spotify_id = $3, musicbrainz_id = $4 WHERE id = $5`,
@@ -447,7 +499,7 @@ func SearchSongs(userId int, query string) ([]Song, float64, error) {
func GetArtistStats(userId, artistId int) (int, error) {
var count int
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)
return count, err
}
@@ -482,8 +534,9 @@ func MergeArtists(userId int, fromArtistId, toArtistId int) error {
func GetHistoryForArtist(userId, artistId int, limit, offset int) ([]ScrobbleEntry, error) {
rows, err := Pool.Query(context.Background(),
`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
FROM history h WHERE h.user_id = $1 AND h.artist_id = $2
(SELECT name FROM artists WHERE id = h.artist_id) as artist_name,
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`,
userId, artistId, limit, offset)
if err != nil {
@@ -494,7 +547,7 @@ func GetHistoryForArtist(userId, artistId int, limit, offset int) ([]ScrobbleEnt
var entries []ScrobbleEntry
for rows.Next() {
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 {
return nil, err
}
@@ -506,7 +559,8 @@ func GetHistoryForArtist(userId, artistId int, limit, offset int) ([]ScrobbleEnt
func GetHistoryForSong(userId, songId int, limit, offset int) ([]ScrobbleEntry, error) {
rows, err := Pool.Query(context.Background(),
`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
ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`,
userId, songId, limit, offset)
@@ -518,7 +572,7 @@ func GetHistoryForSong(userId, songId int, limit, offset int) ([]ScrobbleEntry,
var entries []ScrobbleEntry
for rows.Next() {
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 {
return nil, err
}
@@ -534,6 +588,7 @@ type ScrobbleEntry struct {
AlbumName string
MsPlayed int
Platform string
ArtistIds []int
}
func MigrateHistoryEntities() error {
@@ -651,7 +706,8 @@ func GetAlbumStats(userId, albumId int) (int, error) {
func GetHistoryForAlbum(userId, albumId int, limit, offset int) ([]ScrobbleEntry, error) {
rows, err := Pool.Query(context.Background(),
`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
JOIN songs s ON h.song_id = s.id
WHERE h.user_id = $1 AND s.album_id = $2
@@ -665,7 +721,46 @@ func GetHistoryForAlbum(userId, albumId int, limit, offset int) ([]ScrobbleEntry
var entries []ScrobbleEntry
for rows.Next() {
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 {
return nil, err
}
entries = append(entries, e)
}
return entries, nil
}
func GetSongStatsForSongs(userId int, songIds []int) (int, error) {
if len(songIds) == 0 {
return 0, nil
}
var count int
err := Pool.QueryRow(context.Background(),
"SELECT COUNT(*) FROM history WHERE user_id = $1 AND song_id = ANY($2)",
userId, songIds).Scan(&count)
return count, err
}
func GetHistoryForSongs(userId int, songIds []int, limit, offset int) ([]ScrobbleEntry, error) {
if len(songIds) == 0 {
return []ScrobbleEntry{}, nil
}
rows, err := Pool.Query(context.Background(),
`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,
h.artist_ids
FROM history h WHERE h.user_id = $1 AND h.song_id = ANY($2)
ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`,
userId, songIds, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ScrobbleEntry
for rows.Next() {
var e ScrobbleEntry
err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName, &e.ArtistIds)
if err != nil {
return nil, err
}

View File

@@ -236,20 +236,65 @@ func ImportLastFM(
}
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(),
pgx.Identifier{"history"},
[]string{
"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) {
t := tracks[i]
return []any{
t.UserId, t.Timestamp, t.SongName, t.Artist,
t.Album, 0, "lastfm",
}, nil
}),
pgx.CopyFromRows(rows),
)
*totalImported += int(copyCount)
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
idx int // Current position in tracks slice
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
@@ -107,11 +108,19 @@ func ImportSpotify(tracks []SpotifyTrack,
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{
tracks: validTracks,
tracksToSkip: tracksToSkip,
idx: 0,
userId: userId,
artistIdMap: artistIdMap,
}
copyCount, err := db.Pool.CopyFrom(
@@ -125,6 +134,8 @@ func ImportSpotify(tracks []SpotifyTrack,
"album_name",
"ms_played",
"platform",
"artist_id",
"artist_ids",
},
src,
)
@@ -218,6 +229,43 @@ func getDupes(userId int, tracks []SpotifyTrack) (map[string]struct{}, error) {
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
func findTimeRange(tracks []SpotifyTrack) (time.Time, time.Time) {
var minTs, maxTs time.Time
@@ -319,6 +367,14 @@ func (s *trackSource) Next() bool {
func (s *trackSource) Values() ([]any, error) {
// idx is already incremented in Next(), so use 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{
s.userId,
t.Timestamp,
@@ -327,6 +383,8 @@ func (s *trackSource) Values() ([]any, error) {
t.Album,
t.Played,
"spotify",
primaryArtistId,
artistIds,
}, nil
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"os"
"strings"
"time"
"muzi/db"
@@ -118,33 +119,38 @@ func SaveScrobble(scrobble Scrobble) error {
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 {
fmt.Fprintf(os.Stderr, "Error getting/creating artist: %v\n", err)
return err
}
primaryArtistId := 0
if len(artistIds) > 0 {
primaryArtistId = artistIds[0]
}
var albumId int
if scrobble.Album != "" {
albumId, _, err = db.GetOrCreateAlbum(scrobble.UserId, scrobble.Album, artistId)
albumId, _, err = db.GetOrCreateAlbum(scrobble.UserId, scrobble.Album, primaryArtistId)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting/creating album: %v\n", 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 {
fmt.Fprintf(os.Stderr, "Error getting/creating song: %v\n", err)
return err
}
_, err = db.Pool.Exec(context.Background(),
`INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform, artist_id, song_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`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, $10)
ON CONFLICT (user_id, song_name, artist, timestamp) DO NOTHING`,
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 {
fmt.Fprintf(os.Stderr, "Error saving scrobble: %v\n", err)
return err
@@ -152,6 +158,33 @@ func SaveScrobble(scrobble Scrobble) error {
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) {
if len(scrobbles) == 0 {
return 0, 0, nil

View File

@@ -41,7 +41,7 @@ document.addEventListener('DOMContentLoaded', function() {
clearTimeout(searchTimeout);
if (query.length < 2) {
if (query.length < 1) {
searchResults.classList.remove('active');
searchResults.innerHTML = '';
return;

View File

@@ -265,6 +265,7 @@
flex-direction: row;
align-content: center;
width: 100%;
flex-wrap: wrap;
h1 {
color: #FFFFFF;
margin: 0;
@@ -287,6 +288,19 @@
width: 250px;
height: 250px;
border-radius: 100%;
flex-shrink: 0;
min-width: 0;
}
}
@media (max-width: 600px) {
.profile-top {
justify-content: center;
gap: 15px;
}
.profile-top img {
width: 150px;
height: 150px;
}
}
@@ -320,6 +334,7 @@
justify-content: center;
margin: 5px;
width: 30%;
white-space: pre-wrap;
}
tr:nth-child(even) {
background-color: #111;
@@ -327,6 +342,7 @@
a {
color: #AFA;
text-decoration: none;
white-space: pre-wrap;
}
a:hover {
color: #FFF;

View File

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

View File

@@ -31,9 +31,12 @@
{{$username := .Username}}
{{range .Times}}
<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}}/album/{{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>
</tr>
{{end}}

View File

@@ -9,6 +9,7 @@
</div>
<div class="user-stats-top">
<h3>{{formatInt .ScrobbleCount}}</h3> <p>Listens<p>
<h3>{{formatInt .TrackCount}}</h3> <p>Unique Tracks<p>
<h3>{{formatInt .ArtistCount}}</h3> <p>Artists<p>
</div>
</div>
@@ -27,13 +28,17 @@
<td>Now Playing</td>
</tr>
{{end}}
{{$artists := .Artists}}
{{$artistIdsList := .ArtistIdsList}}
{{$times := .Times}}
{{$username := .Username}}
{{range $index, $title := .Titles}}
<tr>
<td><a href="/profile/{{$username}}/artist/{{urlquery (index $artists $index)}}">{{index $artists $index}}</a></td>
<td><a href="/profile/{{$username}}/song/{{urlquery (index $artists $index)}}/{{urlquery $title}}">{{$title}}</a></td>
<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>
</tr>
{{end}}

View File

@@ -7,11 +7,13 @@
<button class="edit-btn" onclick="openEditModal()">Edit</button>
{{end}}
</h1>
{{if .Artist.Name}}
<h2><a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}">{{.Artist.Name}}</a></h2>
{{if .ArtistNames}}
<h2>
{{- range $i, $name := .ArtistNames}}{{if $i}}, {{end}}<a href="/profile/{{$.Username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
</h2>
{{end}}
{{if .Album.Title}}
<h3><a href="/profile/{{.Username}}/album/{{urlquery .Artist.Name}}/{{urlquery .Album.Title}}">{{.Album.Title}}</a></h3>
{{range .Albums}}
<h3><a href="/profile/{{$.Username}}/album/{{urlquery $.Artist.Name}}/{{urlquery .Title}}">{{.Title}}</a></h3>
{{end}}
</div>
<div class="profile-top-blank">
@@ -32,9 +34,12 @@
{{$username := .Username}}
{{range .Times}}
<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}}/album/{{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>
</tr>
{{end}}

View File

@@ -1,6 +1,7 @@
package web
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -35,7 +36,8 @@ type SongData struct {
Username string
Song db.Song
Artist db.Artist
Album db.Album
ArtistNames []string
Albums []db.Album
ListenCount int
Times []db.ScrobbleEntry
Page int
@@ -48,6 +50,7 @@ type AlbumData struct {
Username string
Album db.Album
Artist db.Artist
ArtistNames []string
ListenCount int
Times []db.ScrobbleEntry
Page int
@@ -151,11 +154,11 @@ func songPageHandler() http.HandlerFunc {
return
}
song, err := db.GetSongByName(userId, songTitle, artist.Id)
if err != nil {
songs, _, searchErr := db.SearchSongs(userId, songTitle)
if searchErr == nil && len(songs) > 0 {
song = songs[0]
songs, err := db.GetSongsByName(userId, songTitle, artist.Id)
if err != nil || len(songs) == 0 {
songList, _, searchErr := db.SearchSongs(userId, songTitle)
if searchErr == nil && len(songList) > 0 {
songs = songList
} else {
fmt.Fprintf(os.Stderr, "Cannot find song %s: %v\n", songTitle, err)
http.Error(w, "Song not found", http.StatusNotFound)
@@ -163,10 +166,48 @@ func songPageHandler() http.HandlerFunc {
}
}
song := songs[0]
artist, _ = db.GetArtistById(song.ArtistId)
var album db.Album
if song.AlbumId > 0 {
album, _ = db.GetAlbumById(song.AlbumId)
var songIds []int
var albums []db.Album
seenAlbums := make(map[int]bool)
seenArtistIds := make(map[int]bool)
var allArtistIds []int
for _, s := range songs {
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] {
seenAlbums[s.AlbumId] = true
album, _ := db.GetAlbumById(s.AlbumId)
albums = append(albums, album)
}
}
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")
@@ -183,12 +224,12 @@ func songPageHandler() http.HandlerFunc {
lim := 15
off := (pageInt - 1) * lim
listenCount, err := db.GetSongStats(userId, song.Id)
listenCount, err := db.GetSongStatsForSongs(userId, songIds)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get song stats: %v\n", err)
}
entries, err := db.GetHistoryForSong(userId, song.Id, lim, off)
entries, err := db.GetHistoryForSongs(userId, songIds, lim, off)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get history for song: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -199,7 +240,8 @@ func songPageHandler() http.HandlerFunc {
Username: username,
Song: song,
Artist: artist,
Album: album,
ArtistNames: artistNames,
Albums: albums,
ListenCount: listenCount,
Times: entries,
Page: pageInt,
@@ -289,7 +331,12 @@ func editSongHandler() http.HandlerFunc {
return
}
http.Redirect(w, r, "/profile/"+username+"/song/"+url.QueryEscape(artist.Name)+"/"+url.QueryEscape(title), http.StatusSeeOther)
http.Redirect(
w,
r,
"/profile/"+username+"/song/"+url.QueryEscape(artist.Name)+"/"+url.QueryEscape(title),
http.StatusSeeOther,
)
}
}
@@ -361,10 +408,25 @@ func albumPageHandler() http.HandlerFunc {
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{
Username: username,
Album: album,
Artist: artist,
ArtistNames: artistNames,
ListenCount: listenCount,
Times: entries,
Page: pageInt,
@@ -422,7 +484,12 @@ func editAlbumHandler() http.HandlerFunc {
return
}
http.Redirect(w, r, "/profile/"+username+"/album/"+url.QueryEscape(artist.Name)+"/"+url.QueryEscape(title), http.StatusSeeOther)
http.Redirect(
w,
r,
"/profile/"+username+"/album/"+url.QueryEscape(artist.Name)+"/"+url.QueryEscape(title),
http.StatusSeeOther,
)
}
}
@@ -472,13 +539,34 @@ func artistInlineEditHandler() http.HandlerFunc {
switch field {
case "name":
artist, _ := db.GetArtistById(artistId)
updateErr = db.UpdateArtist(artistId, req.Value, artist.ImageUrl, artist.Bio, artist.SpotifyId, artist.MusicbrainzId)
updateErr = db.UpdateArtist(
artistId,
req.Value,
artist.ImageUrl,
artist.Bio,
artist.SpotifyId,
artist.MusicbrainzId,
)
case "bio":
artist, _ := db.GetArtistById(artistId)
updateErr = db.UpdateArtist(artistId, artist.Name, artist.ImageUrl, req.Value, artist.SpotifyId, artist.MusicbrainzId)
updateErr = db.UpdateArtist(
artistId,
artist.Name,
artist.ImageUrl,
req.Value,
artist.SpotifyId,
artist.MusicbrainzId,
)
case "image_url":
artist, _ := db.GetArtistById(artistId)
updateErr = db.UpdateArtist(artistId, artist.Name, req.Value, artist.Bio, artist.SpotifyId, artist.MusicbrainzId)
updateErr = db.UpdateArtist(
artistId,
artist.Name,
req.Value,
artist.Bio,
artist.SpotifyId,
artist.MusicbrainzId,
)
default:
http.Error(w, "Invalid field", http.StatusBadRequest)
return
@@ -580,7 +668,13 @@ func songInlineEditHandler() http.HandlerFunc {
}
song, _ := db.GetSongById(songId)
updateErr := db.UpdateSong(songId, req.Value, song.AlbumId, song.SpotifyId, song.MusicbrainzId)
updateErr := db.UpdateSong(
songId,
req.Value,
song.AlbumId,
song.SpotifyId,
song.MusicbrainzId,
)
if updateErr != nil {
fmt.Fprintf(os.Stderr, "Error updating song: %v\n", updateErr)
@@ -763,7 +857,7 @@ func searchHandler() http.HandlerFunc {
}
query := r.URL.Query().Get("q")
if len(query) < 2 {
if len(query) < 1 {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("[]"))
return
@@ -794,7 +888,11 @@ func searchHandler() http.HandlerFunc {
Type: "song",
Name: s.Title,
Artist: artist.Name,
Url: "/profile/" + username + "/song/" + url.QueryEscape(artist.Name) + "/" + url.QueryEscape(s.Title),
Url: "/profile/" + username + "/song/" + url.QueryEscape(
artist.Name,
) + "/" + url.QueryEscape(
s.Title,
),
Count: count,
Score: songSim,
})
@@ -810,7 +908,11 @@ func searchHandler() http.HandlerFunc {
Type: "album",
Name: al.Title,
Artist: artist.Name,
Url: "/profile/" + username + "/album/" + url.QueryEscape(artist.Name) + "/" + url.QueryEscape(al.Title),
Url: "/profile/" + username + "/album/" + url.QueryEscape(
artist.Name,
) + "/" + url.QueryEscape(
al.Title,
),
Count: count,
Score: albumSim,
})
@@ -818,7 +920,11 @@ func searchHandler() http.HandlerFunc {
}
sort.Slice(results, func(i, j int) bool {
return results[i].Score+float64(results[i].Count)*0.01 > results[j].Score+float64(results[j].Count)*0.01
return results[i].Score+float64(
results[i].Count,
)*0.01 > results[j].Score+float64(
results[j].Count,
)*0.01
})
w.Header().Set("Content-Type", "application/json")
@@ -869,7 +975,7 @@ func imageUploadHandler() http.HandlerFunc {
filename := hex.EncodeToString(hashBytes) + ext
uploadDir := "./static/uploads"
if err := os.MkdirAll(uploadDir, 0755); err != nil {
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating upload dir: %v\n", err)
http.Error(w, "Server error", http.StatusInternalServerError)
return

View File

@@ -22,8 +22,10 @@ type ProfileData struct {
Pfp string
AllowDuplicateEdits bool
ScrobbleCount int
TrackCount int
ArtistCount int
Artists []string
ArtistIdsList [][]int
Titles []string
Times []time.Time
Page int
@@ -72,10 +74,11 @@ func profilePageHandler() http.HandlerFunc {
r.Context(),
`SELECT bio, pfp, allow_duplicate_edits,
(SELECT COUNT(*) FROM history WHERE user_id = $1) as scrobble_count,
(SELECT COUNT(*) FROM songs WHERE user_id = $1) as track_count,
(SELECT COUNT(DISTINCT artist) FROM history WHERE user_id = $1) as artist_count
FROM users WHERE pk = $1;`,
userId,
).Scan(&profileData.Bio, &profileData.Pfp, &profileData.AllowDuplicateEdits, &profileData.ScrobbleCount, &profileData.ArtistCount)
).Scan(&profileData.Bio, &profileData.Pfp, &profileData.AllowDuplicateEdits, &profileData.ScrobbleCount, &profileData.TrackCount, &profileData.ArtistCount)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get profile for %s: %v\n", username, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -91,7 +94,7 @@ func profilePageHandler() http.HandlerFunc {
rows, err := db.Pool.Query(
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,
lim,
off,
@@ -104,15 +107,27 @@ func profilePageHandler() http.HandlerFunc {
defer rows.Close()
for rows.Next() {
var artist, title string
var artistId int
var title string
var time pgtype.Timestamptz
err = rows.Scan(&artist, &title, &time)
var artistIds []int
err = rows.Scan(&artistId, &title, &time, &artistIds)
if err != nil {
fmt.Fprintf(os.Stderr, "Scanning history row failed: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
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.Times = append(profileData.Times, time.Time)
}

View File

@@ -5,6 +5,8 @@ package web
import (
"fmt"
"time"
"muzi/db"
)
// Subtracts two integers
@@ -56,3 +58,18 @@ func formatTimestamp(timestamp time.Time) string {
func formatTimestampFull(timestamp time.Time) string {
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,
"formatTimestampFull": formatTimestampFull,
"urlquery": url.QueryEscape,
"getArtistNames": GetArtistNames,
}
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
}