mirror of
https://github.com/riwiwa/muzi.git
synced 2026-04-16 09:25:50 -07:00
Compare commits
6 Commits
181316c343
...
24fb1331b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24fb1331b4 | ||
| 369aae818c | |||
| d73ae51b95 | |||
| 1b6ff0c283 | |||
| 582d3acbc0 | |||
| 7d70d9ea0f |
@@ -17,6 +17,6 @@
|
|||||||
- Ability to specify a certain point in time from one datetime to another to list data
|
- Ability to specify a certain point in time from one datetime to another to list data
|
||||||
- Grid maker (3x3-10x10)
|
- Grid maker (3x3-10x10)
|
||||||
- Ability to change artist and album images \[Complete\]
|
- 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\]
|
- Live scrobbling to the server (With Now playing status) \[Complete\]
|
||||||
- Batch scrobble editor
|
- Batch scrobble editor
|
||||||
|
|||||||
4
db/db.go
4
db/db.go
@@ -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
|
||||||
|
|||||||
111
db/entities.go
111
db/entities.go
@@ -394,6 +394,58 @@ func GetSongByName(userId int, title string, artistId int) (Song, error) {
|
|||||||
return song, nil
|
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 {
|
func UpdateSong(id int, title string, durationMs int, spotifyId, musicbrainzId string) error {
|
||||||
_, err := Pool.Exec(context.Background(),
|
_, err := Pool.Exec(context.Background(),
|
||||||
`UPDATE songs SET title = $1, duration_ms = $2, spotify_id = $3, musicbrainz_id = $4 WHERE id = $5`,
|
`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) {
|
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
|
||||||
}
|
}
|
||||||
@@ -482,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 {
|
||||||
@@ -494,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
|
||||||
}
|
}
|
||||||
@@ -506,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)
|
||||||
@@ -518,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
|
||||||
}
|
}
|
||||||
@@ -534,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 {
|
||||||
@@ -651,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
|
||||||
@@ -665,7 +721,46 @@ 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 {
|
||||||
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
|
|
||||||
if (query.length < 2) {
|
if (query.length < 1) {
|
||||||
searchResults.classList.remove('active');
|
searchResults.classList.remove('active');
|
||||||
searchResults.innerHTML = '';
|
searchResults.innerHTML = '';
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -265,6 +265,7 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
h1 {
|
h1 {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -287,6 +288,19 @@
|
|||||||
width: 250px;
|
width: 250px;
|
||||||
height: 250px;
|
height: 250px;
|
||||||
border-radius: 100%;
|
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;
|
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;
|
||||||
@@ -327,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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -31,9 +31,12 @@
|
|||||||
{{$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 .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>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="user-stats-top">
|
<div class="user-stats-top">
|
||||||
<h3>{{formatInt .ScrobbleCount}}</h3> <p>Listens<p>
|
<h3>{{formatInt .ScrobbleCount}}</h3> <p>Listens<p>
|
||||||
|
<h3>{{formatInt .TrackCount}}</h3> <p>Unique Tracks<p>
|
||||||
<h3>{{formatInt .ArtistCount}}</h3> <p>Artists<p>
|
<h3>{{formatInt .ArtistCount}}</h3> <p>Artists<p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,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}}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
<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}}
|
||||||
{{if .Album.Title}}
|
{{range .Albums}}
|
||||||
<h3><a href="/profile/{{.Username}}/album/{{urlquery .Artist.Name}}/{{urlquery .Album.Title}}">{{.Album.Title}}</a></h3>
|
<h3><a href="/profile/{{$.Username}}/album/{{urlquery $.Artist.Name}}/{{urlquery .Title}}">{{.Title}}</a></h3>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-top-blank">
|
<div class="profile-top-blank">
|
||||||
@@ -32,9 +34,12 @@
|
|||||||
{{$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 .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>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
152
web/entity.go
152
web/entity.go
@@ -1,6 +1,7 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -35,7 +36,8 @@ type SongData struct {
|
|||||||
Username string
|
Username string
|
||||||
Song db.Song
|
Song db.Song
|
||||||
Artist db.Artist
|
Artist db.Artist
|
||||||
Album db.Album
|
ArtistNames []string
|
||||||
|
Albums []db.Album
|
||||||
ListenCount int
|
ListenCount int
|
||||||
Times []db.ScrobbleEntry
|
Times []db.ScrobbleEntry
|
||||||
Page int
|
Page int
|
||||||
@@ -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
|
||||||
@@ -151,11 +154,11 @@ func songPageHandler() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
song, err := db.GetSongByName(userId, songTitle, artist.Id)
|
songs, err := db.GetSongsByName(userId, songTitle, artist.Id)
|
||||||
if err != nil {
|
if err != nil || len(songs) == 0 {
|
||||||
songs, _, searchErr := db.SearchSongs(userId, songTitle)
|
songList, _, searchErr := db.SearchSongs(userId, songTitle)
|
||||||
if searchErr == nil && len(songs) > 0 {
|
if searchErr == nil && len(songList) > 0 {
|
||||||
song = songs[0]
|
songs = songList
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, "Cannot find song %s: %v\n", songTitle, err)
|
fmt.Fprintf(os.Stderr, "Cannot find song %s: %v\n", songTitle, err)
|
||||||
http.Error(w, "Song not found", http.StatusNotFound)
|
http.Error(w, "Song not found", http.StatusNotFound)
|
||||||
@@ -163,10 +166,48 @@ func songPageHandler() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
song := songs[0]
|
||||||
artist, _ = db.GetArtistById(song.ArtistId)
|
artist, _ = db.GetArtistById(song.ArtistId)
|
||||||
var album db.Album
|
|
||||||
if song.AlbumId > 0 {
|
var songIds []int
|
||||||
album, _ = db.GetAlbumById(song.AlbumId)
|
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")
|
pageStr := r.URL.Query().Get("page")
|
||||||
@@ -183,12 +224,12 @@ func songPageHandler() http.HandlerFunc {
|
|||||||
lim := 15
|
lim := 15
|
||||||
off := (pageInt - 1) * lim
|
off := (pageInt - 1) * lim
|
||||||
|
|
||||||
listenCount, err := db.GetSongStats(userId, song.Id)
|
listenCount, err := db.GetSongStatsForSongs(userId, songIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Cannot get song stats: %v\n", err)
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Cannot get history for song: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Cannot get history for song: %v\n", err)
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -199,7 +240,8 @@ func songPageHandler() http.HandlerFunc {
|
|||||||
Username: username,
|
Username: username,
|
||||||
Song: song,
|
Song: song,
|
||||||
Artist: artist,
|
Artist: artist,
|
||||||
Album: album,
|
ArtistNames: artistNames,
|
||||||
|
Albums: albums,
|
||||||
ListenCount: listenCount,
|
ListenCount: listenCount,
|
||||||
Times: entries,
|
Times: entries,
|
||||||
Page: pageInt,
|
Page: pageInt,
|
||||||
@@ -289,7 +331,12 @@ func editSongHandler() http.HandlerFunc {
|
|||||||
return
|
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
|
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,
|
||||||
@@ -422,7 +484,12 @@ func editAlbumHandler() http.HandlerFunc {
|
|||||||
return
|
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 {
|
switch field {
|
||||||
case "name":
|
case "name":
|
||||||
artist, _ := db.GetArtistById(artistId)
|
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":
|
case "bio":
|
||||||
artist, _ := db.GetArtistById(artistId)
|
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":
|
case "image_url":
|
||||||
artist, _ := db.GetArtistById(artistId)
|
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:
|
default:
|
||||||
http.Error(w, "Invalid field", http.StatusBadRequest)
|
http.Error(w, "Invalid field", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -580,7 +668,13 @@ func songInlineEditHandler() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
song, _ := db.GetSongById(songId)
|
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 {
|
if updateErr != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error updating song: %v\n", updateErr)
|
fmt.Fprintf(os.Stderr, "Error updating song: %v\n", updateErr)
|
||||||
@@ -763,7 +857,7 @@ func searchHandler() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
query := r.URL.Query().Get("q")
|
query := r.URL.Query().Get("q")
|
||||||
if len(query) < 2 {
|
if len(query) < 1 {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write([]byte("[]"))
|
w.Write([]byte("[]"))
|
||||||
return
|
return
|
||||||
@@ -794,7 +888,11 @@ func searchHandler() http.HandlerFunc {
|
|||||||
Type: "song",
|
Type: "song",
|
||||||
Name: s.Title,
|
Name: s.Title,
|
||||||
Artist: artist.Name,
|
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,
|
Count: count,
|
||||||
Score: songSim,
|
Score: songSim,
|
||||||
})
|
})
|
||||||
@@ -810,7 +908,11 @@ func searchHandler() http.HandlerFunc {
|
|||||||
Type: "album",
|
Type: "album",
|
||||||
Name: al.Title,
|
Name: al.Title,
|
||||||
Artist: artist.Name,
|
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,
|
Count: count,
|
||||||
Score: albumSim,
|
Score: albumSim,
|
||||||
})
|
})
|
||||||
@@ -818,7 +920,11 @@ func searchHandler() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(results, func(i, j int) bool {
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -869,7 +975,7 @@ func imageUploadHandler() http.HandlerFunc {
|
|||||||
filename := hex.EncodeToString(hashBytes) + ext
|
filename := hex.EncodeToString(hashBytes) + ext
|
||||||
|
|
||||||
uploadDir := "./static/uploads"
|
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)
|
fmt.Fprintf(os.Stderr, "Error creating upload dir: %v\n", err)
|
||||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ type ProfileData struct {
|
|||||||
Pfp string
|
Pfp string
|
||||||
AllowDuplicateEdits bool
|
AllowDuplicateEdits bool
|
||||||
ScrobbleCount int
|
ScrobbleCount 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
|
||||||
@@ -72,10 +74,11 @@ func profilePageHandler() http.HandlerFunc {
|
|||||||
r.Context(),
|
r.Context(),
|
||||||
`SELECT bio, pfp, allow_duplicate_edits,
|
`SELECT bio, pfp, allow_duplicate_edits,
|
||||||
(SELECT COUNT(*) FROM history WHERE user_id = $1) as scrobble_count,
|
(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
|
(SELECT COUNT(DISTINCT artist) FROM history WHERE user_id = $1) as artist_count
|
||||||
FROM users WHERE pk = $1;`,
|
FROM users WHERE pk = $1;`,
|
||||||
userId,
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Cannot get profile for %s: %v\n", username, err)
|
fmt.Fprintf(os.Stderr, "Cannot get profile for %s: %v\n", username, err)
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -91,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,
|
||||||
@@ -104,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)
|
||||||
}
|
}
|
||||||
|
|||||||
17
web/utils.go
17
web/utils.go
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user