Compare commits

...

12 Commits

Author SHA1 Message Date
ace5205724 improve song title editing 2026-03-03 01:31:52 -08:00
f7baf2ee40 add scrobble removal 2026-03-03 00:35:39 -08:00
0dbbaf38ad add manual scrobbling 2026-03-03 00:20:46 -08:00
a9d048a633 add logout 2026-03-02 23:54:05 -08:00
56475df1a0 add top albums and top tracks widgets to profile 2026-03-02 22:50:30 -08:00
6e0e53eb64 add top artists display to profile 2026-03-02 21:58:41 -08:00
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
23 changed files with 2440 additions and 86 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

@@ -7,6 +7,7 @@ import (
"time"
"github.com/jackc/pgtype"
"github.com/jackc/pgx/v5"
)
type Artist struct {
@@ -394,10 +395,68 @@ 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`,
title, durationMs, spotifyId, musicbrainzId, id)
if err != nil {
return err
}
_, err = Pool.Exec(context.Background(),
`UPDATE history SET song_name = $1 WHERE song_id = $2`,
title, id)
return err
}
@@ -447,11 +506,222 @@ 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
}
type TopArtist struct {
Artist Artist
ListenCount int
}
type TopAlbum struct {
AlbumName string
Artist string
CoverUrl string
ListenCount int
}
type TopTrack struct {
SongName string
Artist string
ListenCount int
}
func GetTopArtists(userId int, limit int, startDate, endDate *time.Time) ([]TopArtist, error) {
var err error
var rows pgx.Rows
if startDate == nil && endDate == nil {
rows, err = Pool.Query(context.Background(),
`SELECT a.id, a.user_id, a.name, a.image_url, a.bio, a.spotify_id, a.musicbrainz_id, COUNT(*) as listen_count
FROM artists a
JOIN history h ON h.user_id = a.user_id AND a.id = ANY(h.artist_ids)
WHERE h.user_id = $1
GROUP BY a.id
ORDER BY listen_count DESC
LIMIT $2`,
userId, limit)
} else if startDate != nil && endDate == nil {
rows, err = Pool.Query(context.Background(),
`SELECT a.id, a.user_id, a.name, a.image_url, a.bio, a.spotify_id, a.musicbrainz_id, COUNT(*) as listen_count
FROM artists a
JOIN history h ON h.user_id = a.user_id AND a.id = ANY(h.artist_ids)
WHERE h.user_id = $1 AND h.timestamp >= $2
GROUP BY a.id
ORDER BY listen_count DESC
LIMIT $3`,
userId, startDate, limit)
} else if startDate == nil && endDate != nil {
rows, err = Pool.Query(context.Background(),
`SELECT a.id, a.user_id, a.name, a.image_url, a.bio, a.spotify_id, a.musicbrainz_id, COUNT(*) as listen_count
FROM artists a
JOIN history h ON h.user_id = a.user_id AND a.id = ANY(h.artist_ids)
WHERE h.user_id = $1 AND h.timestamp <= $2
GROUP BY a.id
ORDER BY listen_count DESC
LIMIT $3`,
userId, endDate, limit)
} else {
rows, err = Pool.Query(context.Background(),
`SELECT a.id, a.user_id, a.name, a.image_url, a.bio, a.spotify_id, a.musicbrainz_id, COUNT(*) as listen_count
FROM artists a
JOIN history h ON h.user_id = a.user_id AND a.id = ANY(h.artist_ids)
WHERE h.user_id = $1 AND h.timestamp >= $2 AND h.timestamp <= $3
GROUP BY a.id
ORDER BY listen_count DESC
LIMIT $4`,
userId, startDate, endDate, limit)
}
if err != nil {
return nil, err
}
defer rows.Close()
var topArtists []TopArtist
for rows.Next() {
var a Artist
var count int
var imageUrlPg, bioPg, spotifyIdPg, musicbrainzIdPg pgtype.Text
err := rows.Scan(&a.Id, &a.UserId, &a.Name, &imageUrlPg, &bioPg, &spotifyIdPg, &musicbrainzIdPg, &count)
if err != nil {
return nil, err
}
a.ImageUrl = imageUrlPg.String
a.Bio = bioPg.String
a.SpotifyId = spotifyIdPg.String
a.MusicbrainzId = musicbrainzIdPg.String
topArtists = append(topArtists, TopArtist{Artist: a, ListenCount: count})
}
return topArtists, nil
}
func GetTopAlbums(userId int, limit int, startDate, endDate *time.Time) ([]TopAlbum, error) {
var err error
var rows pgx.Rows
if startDate == nil && endDate == nil {
rows, err = Pool.Query(context.Background(),
`SELECT h.album_name, h.artist, COALESCE(a.cover_url, ''), COUNT(*) as listen_count
FROM history h
LEFT JOIN albums a ON a.user_id = h.user_id AND a.title = h.album_name AND a.artist_id IN (SELECT id FROM artists WHERE user_id = h.user_id AND name = h.artist)
WHERE h.user_id = $1 AND h.album_name IS NOT NULL AND h.album_name != ''
GROUP BY h.album_name, h.artist, a.cover_url
ORDER BY listen_count DESC
LIMIT $2`,
userId, limit)
} else if startDate != nil && endDate == nil {
rows, err = Pool.Query(context.Background(),
`SELECT h.album_name, h.artist, COALESCE(a.cover_url, ''), COUNT(*) as listen_count
FROM history h
LEFT JOIN albums a ON a.user_id = h.user_id AND a.title = h.album_name AND a.artist_id IN (SELECT id FROM artists WHERE user_id = h.user_id AND name = h.artist)
WHERE h.user_id = $1 AND h.timestamp >= $2 AND h.album_name IS NOT NULL AND h.album_name != ''
GROUP BY h.album_name, h.artist, a.cover_url
ORDER BY listen_count DESC
LIMIT $3`,
userId, startDate, limit)
} else if startDate == nil && endDate != nil {
rows, err = Pool.Query(context.Background(),
`SELECT h.album_name, h.artist, COALESCE(a.cover_url, ''), COUNT(*) as listen_count
FROM history h
LEFT JOIN albums a ON a.user_id = h.user_id AND a.title = h.album_name AND a.artist_id IN (SELECT id FROM artists WHERE user_id = h.user_id AND name = h.artist)
WHERE h.user_id = $1 AND h.timestamp <= $2 AND h.album_name IS NOT NULL AND h.album_name != ''
GROUP BY h.album_name, h.artist, a.cover_url
ORDER BY listen_count DESC
LIMIT $3`,
userId, endDate, limit)
} else {
rows, err = Pool.Query(context.Background(),
`SELECT h.album_name, h.artist, COALESCE(a.cover_url, ''), COUNT(*) as listen_count
FROM history h
LEFT JOIN albums a ON a.user_id = h.user_id AND a.title = h.album_name AND a.artist_id IN (SELECT id FROM artists WHERE user_id = h.user_id AND name = h.artist)
WHERE h.user_id = $1 AND h.timestamp >= $2 AND h.timestamp <= $3 AND h.album_name IS NOT NULL AND h.album_name != ''
GROUP BY h.album_name, h.artist, a.cover_url
ORDER BY listen_count DESC
LIMIT $4`,
userId, startDate, endDate, limit)
}
if err != nil {
return nil, err
}
defer rows.Close()
var topAlbums []TopAlbum
for rows.Next() {
var albumName, artist, coverUrl string
var count int
err := rows.Scan(&albumName, &artist, &coverUrl, &count)
if err != nil {
return nil, err
}
topAlbums = append(topAlbums, TopAlbum{AlbumName: albumName, Artist: artist, CoverUrl: coverUrl, ListenCount: count})
}
return topAlbums, nil
}
func GetTopTracks(userId int, limit int, startDate, endDate *time.Time) ([]TopTrack, error) {
var err error
var rows pgx.Rows
if startDate == nil && endDate == nil {
rows, err = Pool.Query(context.Background(),
`SELECT song_name, artist, COUNT(*) as listen_count
FROM history
WHERE user_id = $1
GROUP BY song_name, artist
ORDER BY listen_count DESC
LIMIT $2`,
userId, limit)
} else if startDate != nil && endDate == nil {
rows, err = Pool.Query(context.Background(),
`SELECT song_name, artist, COUNT(*) as listen_count
FROM history
WHERE user_id = $1 AND timestamp >= $2
GROUP BY song_name, artist
ORDER BY listen_count DESC
LIMIT $3`,
userId, startDate, limit)
} else if startDate == nil && endDate != nil {
rows, err = Pool.Query(context.Background(),
`SELECT song_name, artist, COUNT(*) as listen_count
FROM history
WHERE user_id = $1 AND timestamp <= $2
GROUP BY song_name, artist
ORDER BY listen_count DESC
LIMIT $3`,
userId, endDate, limit)
} else {
rows, err = Pool.Query(context.Background(),
`SELECT song_name, artist, COUNT(*) as listen_count
FROM history
WHERE user_id = $1 AND timestamp >= $2 AND timestamp <= $3
GROUP BY song_name, artist
ORDER BY listen_count DESC
LIMIT $4`,
userId, startDate, endDate, limit)
}
if err != nil {
return nil, err
}
defer rows.Close()
var topTracks []TopTrack
for rows.Next() {
var songName, artist string
var count int
err := rows.Scan(&songName, &artist, &count)
if err != nil {
return nil, err
}
topTracks = append(topTracks, TopTrack{SongName: songName, Artist: artist, ListenCount: count})
}
return topTracks, nil
}
func GetSongStats(userId, songId int) (int, error) {
var count int
err := Pool.QueryRow(context.Background(),
@@ -482,8 +752,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 +765,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 +777,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 +790,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
}
@@ -528,12 +800,14 @@ func GetHistoryForSong(userId, songId int, limit, offset int) ([]ScrobbleEntry,
}
type ScrobbleEntry struct {
Id int
Timestamp time.Time
SongName string
ArtistName string
AlbumName string
MsPlayed int
Platform string
ArtistIds []int
}
func MigrateHistoryEntities() error {
@@ -651,7 +925,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 +940,7 @@ 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
}
@@ -673,3 +948,56 @@ func GetHistoryForAlbum(userId, albumId int, limit, offset int) ([]ScrobbleEntry
}
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.id, 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.Id, &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 DeleteHistoryByIds(userId int, ids []int) error {
if len(ids) == 0 {
return nil
}
_, err := Pool.Exec(context.Background(),
`DELETE FROM history WHERE user_id = $1 AND id = ANY($2)`,
userId, ids)
if err != nil {
fmt.Fprintf(os.Stderr, "Error deleting history: %v\n", err)
return err
}
return nil
}

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

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>

After

Width:  |  Height:  |  Size: 259 B

View File

@@ -0,0 +1,11 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>

After

Width:  |  Height:  |  Size: 257 B

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;
@@ -169,18 +169,13 @@ document.addEventListener('DOMContentLoaded', function() {
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
// Update bio display if it exists
var bioDisplay = document.getElementById('bio-display');
if (bioDisplay && data.bio !== undefined) {
bioDisplay.textContent = data.bio;
}
// Update info display if it exists
var infoDisplay = document.getElementById('info-display');
if (infoDisplay && data.title !== undefined) {
// Will be reloaded anyway, but close modal first
}
closeEditModal();
var response = JSON.parse(xhr.responseText);
if (response.success && entityType === 'song' && response.artist && response.title && response.username) {
var newUrl = '/profile/' + response.username + '/song/' + encodeURIComponent(response.artist) + '/' + encodeURIComponent(response.title);
window.location.href = newUrl;
} else {
location.reload();
}
} else {
alert('Error saving: ' + xhr.responseText);
}

133
static/profile.js Normal file
View File

@@ -0,0 +1,133 @@
function updateTopArtists() {
const period = document.getElementById('period-select').value;
const limit = document.getElementById('limit-select').value;
const view = document.getElementById('view-select').value;
const customDates = document.getElementById('custom-dates');
if (period === 'custom') {
customDates.style.display = 'inline-block';
} else {
customDates.style.display = 'none';
}
const params = new URLSearchParams(window.location.search);
params.set('period', period);
params.set('limit', limit);
params.set('view', view);
if (period === 'custom') {
const startDate = document.getElementById('start-date').value;
const endDate = document.getElementById('end-date').value;
if (startDate) params.set('start', startDate);
if (endDate) params.set('end', endDate);
}
window.location.search = params.toString();
}
function updateLimitOptions() {
const view = document.getElementById('view-select').value;
const limitSelect = document.getElementById('limit-select');
const maxLimit = view === 'grid' ? 8 : 30;
for (let option of limitSelect.options) {
const value = parseInt(option.value);
if (value > maxLimit || (view === 'grid' && value === 7)) {
option.style.display = 'none';
} else {
option.style.display = '';
}
}
if (parseInt(limitSelect.value) > maxLimit || (view === 'grid' && parseInt(limitSelect.value) === 7)) {
limitSelect.value = maxLimit;
}
}
function updateTopAlbums() {
const period = document.getElementById('album-period-select').value;
const limit = document.getElementById('album-limit-select').value;
const view = document.getElementById('album-view-select').value;
const customDates = document.getElementById('album-custom-dates');
if (period === 'custom') {
customDates.style.display = 'inline-block';
} else {
customDates.style.display = 'none';
}
const params = new URLSearchParams(window.location.search);
params.set('album_period', period);
params.set('album_limit', limit);
params.set('album_view', view);
if (period === 'custom') {
const startDate = document.getElementById('album-start-date').value;
const endDate = document.getElementById('album-end-date').value;
if (startDate) params.set('album_start', startDate);
if (endDate) params.set('album_end', endDate);
}
window.location.search = params.toString();
}
function updateTopAlbumsLimitOptions() {
const view = document.getElementById('album-view-select').value;
const limitSelect = document.getElementById('album-limit-select');
const maxLimit = view === 'grid' ? 8 : 30;
for (let option of limitSelect.options) {
const value = parseInt(option.value);
if (value > maxLimit || (view === 'grid' && value === 7)) {
option.style.display = 'none';
} else {
option.style.display = '';
}
}
if (parseInt(limitSelect.value) > maxLimit || (view === 'grid' && parseInt(limitSelect.value) === 7)) {
limitSelect.value = maxLimit;
}
}
function updateTopTracks() {
const period = document.getElementById('track-period-select').value;
const limit = document.getElementById('track-limit-select').value;
const customDates = document.getElementById('track-custom-dates');
if (period === 'custom') {
customDates.style.display = 'inline-block';
} else {
customDates.style.display = 'none';
}
const params = new URLSearchParams(window.location.search);
params.set('track_period', period);
params.set('track_limit', limit);
if (period === 'custom') {
const startDate = document.getElementById('track-start-date').value;
const endDate = document.getElementById('track-end-date').value;
if (startDate) params.set('track_start', startDate);
if (endDate) params.set('track_end', endDate);
}
window.location.search = params.toString();
}
function syncGridHeights() {}
document.addEventListener('DOMContentLoaded', function() {
const customDates = document.getElementById('custom-dates');
const periodSelect = document.getElementById('period-select');
if (periodSelect && customDates) {
if (periodSelect.value === 'custom') {
customDates.style.display = 'inline-block';
}
}
updateLimitOptions();
updateTopAlbumsLimitOptions();
});

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;
@@ -744,3 +760,487 @@ a.button:hover {
margin: 0;
line-height: 1.6;
}
/* Top Artists Section */
.top-artists {
width: 50%;
margin: 15px 0;
padding: 10px;
background: #1a1a1a;
border-radius: 10px;
}
.top-albums {
width: 50%;
margin: 15px 0;
padding: 10px;
background: #1a1a1a;
border-radius: 10px;
}
.top-tracks {
width: 50%;
margin: 15px 0;
padding: 10px;
background: #1a1a1a;
border-radius: 10px;
}
.top-artists-controls {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
}
.top-albums-controls {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
}
.top-tracks-controls {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
}
#top-artists-display {
min-height: 150px;
}
#top-albums-display {
min-height: 150px;
}
#top-tracks-display {
min-height: 150px;
}
.top-artists-controls h3 {
margin: 0;
}
.controls-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
max-width: 100%;
}
.controls-row label {
display: flex;
align-items: center;
gap: 8px;
color: #888;
font-size: 14px;
}
.controls-row select {
padding: 6px 10px;
border: 1px solid #444;
border-radius: 4px;
background: #333;
color: #AFA;
font-size: 14px;
cursor: pointer;
}
.controls-row select:hover {
border-color: #AFA;
}
.controls-row input[type="date"] {
padding: 6px 10px;
border: 1px solid #444;
border-radius: 4px;
background: #333;
color: #AFA;
font-size: 14px;
}
/* Artist Grid - New layout */
.artist-grid {
display: flex;
flex-direction: column;
gap: 8px;
list-style: none;
padding: 0;
margin: 0;
}
.artist-grid-odd {
flex-direction: row;
}
.artist-grid-odd .artist-cell-first {
aspect-ratio: 1;
flex: 1;
}
.artist-grid-odd .artist-right-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.artist-row {
display: flex;
gap: 8px;
flex: 1;
}
.artist-grid-odd .artist-row {
flex: 1;
}
.artist-cell {
flex: 1;
display: block;
background: #2a2a2a;
transition: background 0.2s;
transition: transform 0.1s ease;
border-radius: 8px;
min-width: 0;
}
.artist-cell-first {
display: block;
transition: background 0.2s;
transition: transform 0.1s ease;
border-radius: 8px;
}
.artist-cell a,
.artist-cell-first a {
display: block;
text-decoration: none;
}
.artist-cell,
.artist-cell-first {
position: relative;
aspect-ratio: 1;
}
.artist-cell .grid-items-cover-image,
.artist-cell-first .grid-items-cover-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.artist-cell .grid-items-cover-image-image,
.artist-cell-first .grid-items-cover-image-image {
position: relative;
width: 100%;
height: 100%;
}
.artist-cell .grid-items-cover-image-image img,
.artist-cell-first .grid-items-cover-image-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 10px;
}
.artist-cell .artist-placeholder,
.artist-cell-first .artist-placeholder {
width: 100%;
height: 100%;
background: #333;
border-radius: 10px;
}
.artist-cell:hover .grid-items-cover-image-image img,
.artist-cell-first:hover .grid-items-cover-image-image img,
.artist-cell:hover .artist-placeholder,
.artist-cell-first:hover .artist-placeholder {
opacity: 0.9;
}
.artist-cell .grid-items-item-details,
.artist-cell-first .grid-items-item-details {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 8px;
background: linear-gradient(#0000008c, rgba(0,0,0,0.8));
box-sizing: border-box;
border-radius: 0 0 10px 10px;
}
.artist-cell .grid-items-item-main-text,
.artist-cell-first .grid-items-item-main-text {
color: #fff;
font-weight: bold;
font-size: 14px;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist-cell .grid-items-item-aux-text,
.artist-cell-first .grid-items-item-aux-text {
color: #aaa;
font-size: 12px;
margin: 0;
}
/* List View */
.artist-list {
display: flex;
flex-direction: column;
gap: 6px;
}
overflow: hidden;
text-decoration: none;
}
.grid-large .artist-card-large img,
.grid-large .artist-card-large .artist-placeholder-large {
width: 100%;
height: 100%;
object-fit: cover;
}
.grid-large .small-group {
display: contents;
}
.grid-large .small-row {
display: contents;
}
.grid-large .artist-card-small {
position: relative;
border-radius: 8px;
overflow: hidden;
text-decoration: none;
}
.grid-large .artist-card-small img,
.grid-large .artist-card-small .artist-placeholder-small {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
}
.artist-placeholder-large {
width: 100%;
height: 100%;
min-height: 300px;
background: #333;
border-radius: 8px;
}
.grid-small-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.artist-card-large,
.artist-card-small {
display: block;
position: relative;
border-radius: 8px;
overflow: hidden;
text-decoration: none;
}
.artist-card-large img,
.artist-card-small img {
width: 100%;
height: 100px;
object-fit: cover;
transition: transform 0.2s;
}
.artist-card-large:hover img,
.artist-card-small:hover img {
transform: scale(1.05);
}
.artist-card-large img,
.artist-card-small img {
width: 100%;
height: 200px;
object-fit: cover;
transition: transform 0.2s;
}
.artist-card-large:hover img,
.artist-card-small:hover img {
transform: scale(1.05);
}
.artist-placeholder-small {
width: 100%;
height: 150px;
background: #333;
border-radius: 8px;
}
.artist-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 12px 6px 4px;
background: linear-gradient(transparent, rgba(0,0,0,0.9));
display: flex;
flex-direction: column;
gap: 1px;
}
.artist-name {
color: #fff;
font-weight: bold;
font-size: 9px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artist-count {
color: #AFA;
font-size: 8px;
}
/* Grid View - Even */
.grid-even {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px;
}
.grid-even .small-row {
display: contents;
}
.grid-even .artist-card-small {
position: relative;
border-radius: 8px;
overflow: hidden;
text-decoration: none;
}
.grid-even .artist-card-small img,
.grid-even .artist-card-small .artist-placeholder-small {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
.grid-even .artist-card-small img,
.grid-even .artist-card-small .artist-placeholder-small {
width: 100%;
height: 100px;
object-fit: cover;
}
/* List View */
.artist-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.artist-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px;
border-radius: 8px;
text-decoration: none;
}
.artist-cell:hover {
background: #333;
transform: scale(0.98);
}
.artist-cell-first:hover {
background: #333;
transform: scale(0.98);
}
.artist-row img {
width: 35px;
height: 35px;
object-fit: cover;
border-radius: 50%;
flex-shrink: 0;
}
.artist-placeholder-row {
width: 35px;
height: 35px;
background: #444;
border-radius: 50%;
flex-shrink: 0;
}
.artist-row .artist-name {
flex: 1;
text-align: left;
font-size: 12px;
}
.artist-row .artist-count {
text-align: right;
font-size: 11px;
}
/* Responsive Artist Grid */
@media (max-width: 600px) {
.artist-grid {
max-width: 100%;
}
.artist-grid-odd {
flex-direction: column;
}
.artist-grid-odd .artist-cell-first {
width: 100%;
max-width: 300px;
margin: 0 auto;
}
.artist-grid-odd .artist-right-col {
flex-direction: row;
}
.artist-row {
flex-wrap: wrap;
}
.artist-cell {
min-width: calc(50% - 4px);
}
}
@media (max-width: 400px) {
.artist-grid-odd .artist-right-col {
flex-direction: column;
}
.artist-cell {
min-width: 100%;
}
}

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

@@ -31,6 +31,10 @@
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Profile">
<span>My Profile</span>
</a>
<a href="/logout" class="menu-item">
<img src="/files/assets/icons/logout.svg" class="menu-icon" alt="Logout">
<span>Logout</span>
</a>
{{else}}
<a href="/login" class="menu-item">
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Login">
@@ -41,6 +45,10 @@
<img src="/files/assets/icons/settings.svg" class="menu-icon" alt="Settings">
<span>Settings</span>
</a>
<a href="/scrobble" class="menu-item">
<img src="/files/assets/icons/add.svg" class="menu-icon" alt="Scrobble">
<span>Manual Scrobble</span>
</a>
</nav>
</div>
@@ -53,8 +61,12 @@
{{ if eq .TemplateName "artist"}}{{block "artist" .}}{{end}}{{end}}
{{ if eq .TemplateName "song"}}{{block "song" .}}{{end}}{{end}}
{{ if eq .TemplateName "album"}}{{block "album" .}}{{end}}{{end}}
{{ if eq .TemplateName "scrobble"}}{{block "scrobble" .}}{{end}}{{end}}
<script src="/files/menu.js"></script>
{{if eq .TemplateName "profile"}}
<script src="/files/profile.js"></script>
{{end}}
</body>
</html>
{{end}}

View File

@@ -9,9 +9,352 @@
</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>
<div class="top-artists">
<div class="top-artists-controls">
<h3>Top Artists</h3>
<div class="controls-row">
<label>
Period:
<select id="period-select" onchange="updateTopArtists()">
<option value="all_time" {{if eq .TopArtistsPeriod "all_time"}}selected{{end}}>All Time</option>
<option value="week" {{if eq .TopArtistsPeriod "week"}}selected{{end}}>Last 7 Days</option>
<option value="month" {{if eq .TopArtistsPeriod "month"}}selected{{end}}>Last 30 Days</option>
<option value="year" {{if eq .TopArtistsPeriod "year"}}selected{{end}}>Last Year</option>
<option value="custom" {{if eq .TopArtistsPeriod "custom"}}selected{{end}}>Custom</option>
</select>
</label>
<div id="custom-dates" style="display: {{if eq .TopArtistsPeriod "custom"}}inline-block{{else}}none{{end}};">
<input type="date" id="start-date" onchange="updateTopArtists()">
<input type="date" id="end-date" onchange="updateTopArtists()">
</div>
<label>
Count:
<select id="limit-select" onchange="updateTopArtists()">
<option value="5" {{if eq .TopArtistsLimit 5}}selected{{end}}>5</option>
<option value="6" {{if eq .TopArtistsLimit 6}}selected{{end}}>6</option>
<option value="7" {{if eq .TopArtistsLimit 7}}selected{{end}}>7</option>
<option value="8" {{if eq .TopArtistsLimit 8}}selected{{end}}>8</option>
<option value="9" {{if eq .TopArtistsLimit 9}}selected{{end}}>9</option>
<option value="10" {{if eq .TopArtistsLimit 10}}selected{{end}}>10</option>
<option value="15" {{if eq .TopArtistsLimit 15}}selected{{end}}>15</option>
<option value="20" {{if eq .TopArtistsLimit 20}}selected{{end}}>20</option>
<option value="25" {{if eq .TopArtistsLimit 25}}selected{{end}}>25</option>
<option value="30" {{if eq .TopArtistsLimit 30}}selected{{end}}>30</option>
</select>
</label>
<label>
View:
<select id="view-select" onchange="updateLimitOptions(); updateTopArtists()">
<option value="grid" {{if eq .TopArtistsView "grid"}}selected{{end}}>Grid</option>
<option value="list" {{if eq .TopArtistsView "list"}}selected{{end}}>List</option>
</select>
</label>
</div>
</div>
{{if .TopArtists}}
<div id="top-artists-display" class="top-artists-{{.TopArtistsView}}">
{{$view := .TopArtistsView}}
{{$artists := .TopArtists}}
{{$len := len $artists}}
{{if eq $view "grid"}}
<div class="artist-grid {{if mod $len 2}}artist-grid-odd{{end}}">
{{if mod $len 2}}
<div class="artist-cell-first">
<a href="/profile/{{$.Username}}/artist/{{urlquery (index $artists 0).Artist.Name}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if (index $artists 0).Artist.ImageUrl}}
<img src="{{(index $artists 0).Artist.ImageUrl}}" alt="{{(index $artists 0).Artist.Name}}">
{{else}}
<div class="artist-placeholder"></div>
{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{(index $artists 0).Artist.Name}}</p>
<p class="grid-items-item-aux-text">{{formatInt (index $artists 0).ListenCount}} plays</p>
</div>
</a>
</div>
<div class="artist-right-col">
<div class="artist-row">
{{range $i, $a := slice $artists 1 (add 1 (div (sub $len 1) 2))}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.Artist.Name}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
<div class="artist-row">
{{range $i, $a := slice $artists (add 1 (div (sub $len 1) 2)) $len}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.Artist.Name}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
</div>
{{else}}
<div class="artist-row">
{{range $i, $a := slice $artists 0 (div $len 2)}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.Artist.Name}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
<div class="artist-row">
{{range $i, $a := slice $artists (div $len 2) $len}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.Artist.Name}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="artist-list">
{{range $a := $artists}}
<a href="/profile/{{$.Username}}/artist/{{urlquery $a.Artist.Name}}" class="artist-row">
{{if $a.Artist.ImageUrl}}<img src="{{$a.Artist.ImageUrl}}" alt="{{$a.Artist.Name}}">{{else}}<div class="artist-placeholder-row"></div>{{end}}
<span class="artist-name">{{$a.Artist.Name}}</span>
<span class="artist-count">{{formatInt $a.ListenCount}} plays</span>
</a>
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="top-albums">
<div class="top-albums-controls">
<h3>Top Albums</h3>
<div class="controls-row">
<label>
Period:
<select id="album-period-select" onchange="updateTopAlbums()">
<option value="all_time" {{if eq .TopAlbumsPeriod "all_time"}}selected{{end}}>All Time</option>
<option value="week" {{if eq .TopAlbumsPeriod "week"}}selected{{end}}>Last 7 Days</option>
<option value="month" {{if eq .TopAlbumsPeriod "month"}}selected{{end}}>Last 30 Days</option>
<option value="year" {{if eq .TopAlbumsPeriod "year"}}selected{{end}}>Last Year</option>
<option value="custom" {{if eq .TopAlbumsPeriod "custom"}}selected{{end}}>Custom</option>
</select>
</label>
<div id="album-custom-dates" style="display: {{if eq .TopAlbumsPeriod "custom"}}inline-block{{else}}none{{end}};">
<input type="date" id="album-start-date" onchange="updateTopAlbums()">
<input type="date" id="album-end-date" onchange="updateTopAlbums()">
</div>
<label>
Count:
<select id="album-limit-select" onchange="updateTopAlbums()">
<option value="5" {{if eq .TopAlbumsLimit 5}}selected{{end}}>5</option>
<option value="6" {{if eq .TopAlbumsLimit 6}}selected{{end}}>6</option>
<option value="7" {{if eq .TopAlbumsLimit 7}}selected{{end}}>7</option>
<option value="8" {{if eq .TopAlbumsLimit 8}}selected{{end}}>8</option>
<option value="9" {{if eq .TopAlbumsLimit 9}}selected{{end}}>9</option>
<option value="10" {{if eq .TopAlbumsLimit 10}}selected{{end}}>10</option>
<option value="15" {{if eq .TopAlbumsLimit 15}}selected{{end}}>15</option>
<option value="20" {{if eq .TopAlbumsLimit 20}}selected{{end}}>20</option>
<option value="25" {{if eq .TopAlbumsLimit 25}}selected{{end}}>25</option>
<option value="30" {{if eq .TopAlbumsLimit 30}}selected{{end}}>30</option>
</select>
</label>
<label>
View:
<select id="album-view-select" onchange="updateTopAlbumsLimitOptions(); updateTopAlbums()">
<option value="grid" {{if eq .TopAlbumsView "grid"}}selected{{end}}>Grid</option>
<option value="list" {{if eq .TopAlbumsView "list"}}selected{{end}}>List</option>
</select>
</label>
</div>
</div>
{{if .TopAlbums}}
<div id="top-albums-display" class="top-albums-{{.TopAlbumsView}}">
{{$view := .TopAlbumsView}}
{{$albums := .TopAlbums}}
{{$len := len $albums}}
{{if eq $view "grid"}}
<div class="artist-grid {{if mod $len 2}}artist-grid-odd{{end}}">
{{if mod $len 2}}
<div class="artist-cell-first">
<a href="/profile/{{$.Username}}/album/{{urlquery (index $albums 0).Artist}}/{{urlquery (index $albums 0).AlbumName}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if (index $albums 0).CoverUrl}}
<img src="{{(index $albums 0).CoverUrl}}" alt="{{(index $albums 0).AlbumName}}">
{{else}}
<div class="artist-placeholder"></div>
{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{(index $albums 0).AlbumName}}</p>
<p class="grid-items-item-aux-text">{{(index $albums 0).Artist}}</p>
<p class="grid-items-item-aux-text">{{formatInt (index $albums 0).ListenCount}} plays</p>
</div>
</a>
</div>
<div class="artist-right-col">
<div class="artist-row">
{{range $i, $a := sliceAlbum $albums 1 (add 1 (div (sub $len 1) 2))}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.AlbumName}}</p>
<p class="grid-items-item-aux-text">{{$a.Artist}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
<div class="artist-row">
{{range $i, $a := sliceAlbum $albums (add 1 (div (sub $len 1) 2)) $len}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.AlbumName}}</p>
<p class="grid-items-item-aux-text">{{$a.Artist}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
</div>
{{else}}
<div class="artist-row">
{{range $i, $a := sliceAlbum $albums 0 (div $len 2)}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.AlbumName}}</p>
<p class="grid-items-item-aux-text">{{$a.Artist}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
<div class="artist-row">
{{range $i, $a := sliceAlbum $albums (div $len 2) $len}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.AlbumName}}</p>
<p class="grid-items-item-aux-text">{{$a.Artist}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="artist-list">
{{range $a := $albums}}
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="artist-row">
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder-row"></div>{{end}}
<span class="artist-name">{{$a.AlbumName}} - {{$a.Artist}}</span>
<span class="artist-count">{{formatInt $a.ListenCount}} plays</span>
</a>
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="top-tracks">
<div class="top-tracks-controls">
<h3>Top Tracks</h3>
<div class="controls-row">
<label>
Period:
<select id="track-period-select" onchange="updateTopTracks()">
<option value="all_time" {{if eq .TopTracksPeriod "all_time"}}selected{{end}}>All Time</option>
<option value="week" {{if eq .TopTracksPeriod "week"}}selected{{end}}>Last 7 Days</option>
<option value="month" {{if eq .TopTracksPeriod "month"}}selected{{end}}>Last 30 Days</option>
<option value="year" {{if eq .TopTracksPeriod "year"}}selected{{end}}>Last Year</option>
<option value="custom" {{if eq .TopTracksPeriod "custom"}}selected{{end}}>Custom</option>
</select>
</label>
<div id="track-custom-dates" style="display: {{if eq .TopTracksPeriod "custom"}}inline-block{{else}}none{{end}};">
<input type="date" id="track-start-date" onchange="updateTopTracks()">
<input type="date" id="track-end-date" onchange="updateTopTracks()">
</div>
<label>
Count:
<select id="track-limit-select" onchange="updateTopTracks()">
<option value="5" {{if eq .TopTracksLimit 5}}selected{{end}}>5</option>
<option value="6" {{if eq .TopTracksLimit 6}}selected{{end}}>6</option>
<option value="7" {{if eq .TopTracksLimit 7}}selected{{end}}>7</option>
<option value="8" {{if eq .TopTracksLimit 8}}selected{{end}}>8</option>
<option value="9" {{if eq .TopTracksLimit 9}}selected{{end}}>9</option>
<option value="10" {{if eq .TopTracksLimit 10}}selected{{end}}>10</option>
<option value="15" {{if eq .TopTracksLimit 15}}selected{{end}}>15</option>
<option value="20" {{if eq .TopTracksLimit 20}}selected{{end}}>20</option>
<option value="25" {{if eq .TopTracksLimit 25}}selected{{end}}>25</option>
<option value="30" {{if eq .TopTracksLimit 30}}selected{{end}}>30</option>
</select>
</label>
</div>
</div>
{{if .TopTracks}}
<div id="top-tracks-display">
{{$tracks := .TopTracks}}
<div class="artist-list">
{{range $t := $tracks}}
<a href="/profile/{{$.Username}}/song/{{urlquery $t.Artist}}/{{urlquery $t.SongName}}" class="artist-row">
<span class="artist-name">{{$t.SongName}} - {{$t.Artist}}</span>
<span class="artist-count">{{formatInt $t.ListenCount}} plays</span>
</a>
{{end}}
</div>
</div>
{{end}}
</div>
<div class="history">
<h3>Listening History</h3>
<table>
@@ -27,14 +370,18 @@
<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 title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</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><span title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</span></td>
</tr>
{{end}}
</table>

148
templates/scrobble.gohtml Normal file
View File

@@ -0,0 +1,148 @@
{{define "scrobble"}}
<div class="scrobble-container">
<h1>Manual Scrobble</h1>
<p>Manually add listening history entries.</p>
<div id="message" class="message" style="display: none;"></div>
<form id="scrobble-form">
<div id="rows-container">
<div class="scrobble-row">
<input type="text" name="song_name" placeholder="Song Name" required>
<input type="text" name="artist" placeholder="Artist" required>
<input type="text" name="album_name" placeholder="Album (optional)">
<input type="date" name="date" required>
<input type="time" name="time" required step="1">
<input type="number" name="duration" placeholder="Duration (sec)" min="0">
<button type="button" class="remove-row" style="display: none;">&times;</button>
</div>
</div>
<div class="scrobble-actions">
<button type="button" id="add-row">+ Add Row</button>
<button type="submit">Submit All</button>
</div>
</form>
</div>
<script>
const now = new Date();
const dateStr = now.toISOString().split('T')[0];
const timeStr = now.toTimeString().split(' ')[0];
document.querySelectorAll('.scrobble-row').forEach(row => {
row.querySelector('input[name="date"]').value = dateStr;
row.querySelector('input[name="time"]').value = timeStr;
});
document.getElementById('add-row').addEventListener('click', () => {
const container = document.getElementById('rows-container');
const newRow = document.createElement('div');
newRow.className = 'scrobble-row';
newRow.innerHTML = `
<input type="text" name="song_name" placeholder="Song Name" required>
<input type="text" name="artist" placeholder="Artist" required>
<input type="text" name="album_name" placeholder="Album (optional)">
<input type="date" name="date" required value="${dateStr}">
<input type="time" name="time" required step="1" value="${timeStr}">
<input type="number" name="duration" placeholder="Duration (sec)" min="0">
<button type="button" class="remove-row">&times;</button>
`;
container.appendChild(newRow);
updateRemoveButtons();
});
document.getElementById('rows-container').addEventListener('click', (e) => {
if (e.target.classList.contains('remove-row')) {
e.target.closest('.scrobble-row').remove();
updateRemoveButtons();
}
});
function updateRemoveButtons() {
const rows = document.querySelectorAll('.scrobble-row');
const buttons = document.querySelectorAll('.remove-row');
buttons.forEach(btn => {
btn.style.display = rows.length > 1 ? 'block' : 'none';
});
}
document.getElementById('scrobble-form').addEventListener('submit', async (e) => {
e.preventDefault();
const rows = document.querySelectorAll('.scrobble-row');
const tracks = [];
for (const row of rows) {
const songName = row.querySelector('input[name="song_name"]').value.trim();
const artist = row.querySelector('input[name="artist"]').value.trim();
const albumName = row.querySelector('input[name="album_name"]').value.trim();
const date = row.querySelector('input[name="date"]').value;
const time = row.querySelector('input[name="time"]').value;
const duration = row.querySelector('input[name="duration"]').value;
if (!songName || !artist || !date || !time) continue;
const timestamp = new Date(`${date}T${time}`).toISOString();
const msPlayed = duration ? parseInt(duration) * 1000 : 0;
tracks.push({
song_name: songName,
artist: artist,
album_name: albumName,
timestamp: timestamp,
ms_played: msPlayed
});
}
if (tracks.length === 0) {
showMessage('Please fill in at least one complete row.', 'error');
return;
}
try {
const response = await fetch('/scrobble', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tracks })
});
const result = await response.json();
if (response.ok) {
showMessage(`Successfully scrobbled ${result.count} track(s)!`, 'success');
resetForm();
} else {
showMessage(result.error || 'Failed to scrobble tracks.', 'error');
}
} catch (err) {
showMessage('An error occurred. Please try again.', 'error');
}
});
function showMessage(text, type) {
const msg = document.getElementById('message');
msg.textContent = text;
msg.className = 'message ' + type;
msg.style.display = 'block';
}
function resetForm() {
const container = document.getElementById('rows-container');
container.innerHTML = `
<div class="scrobble-row">
<input type="text" name="song_name" placeholder="Song Name" required>
<input type="text" name="artist" placeholder="Artist" required>
<input type="text" name="album_name" placeholder="Album (optional)">
<input type="date" name="date" required value="${dateStr}">
<input type="time" name="time" required step="1" value="${timeStr}">
<input type="number" name="duration" placeholder="Duration (sec)" min="0">
<button type="button" class="remove-row" style="display: none;">&times;</button>
</div>
`;
updateRemoveButtons();
}
updateRemoveButtons();
</script>
{{end}}

View File

@@ -5,13 +5,16 @@
{{.Song.Title}}
{{if eq .LoggedInUsername .Username}}
<button class="edit-btn" onclick="openEditModal()">Edit</button>
<button class="edit-btn" id="removeScrobblesBtn" onclick="toggleRemoveMode()">Remove</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">
@@ -21,9 +24,16 @@
</div>
</div>
<div class="history">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3>Scrobbles</h3>
<div id="removeControls" style="display: none;">
<button class="edit-btn" onclick="cancelRemoveMode()">Cancel</button>
<button class="edit-btn" onclick="deleteSelectedScrobbles()" style="background: #c44;">Delete Selected</button>
</div>
</div>
<table>
<tr>
<th class="remove-checkbox-col" style="display: none;"><input type="checkbox" id="selectAllCheckboxes"></th>
<th>Artist</th>
<th>Title</th>
<th>Album</th>
@@ -32,9 +42,15 @@
{{$username := .Username}}
{{range .Times}}
<tr>
<td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td>
<td class="remove-checkbox-col" style="display: none;">
<input type="checkbox" class="scrobble-checkbox" value="{{.Id}}">
</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}}
@@ -63,4 +79,67 @@
</div>
</div>
{{end}}
<script>
function toggleRemoveMode() {
var checkboxes = document.querySelectorAll('.remove-checkbox-col');
checkboxes.forEach(function(col) {
col.style.display = '';
});
document.getElementById('removeScrobblesBtn').style.display = 'none';
document.getElementById('removeControls').style.display = '';
var selectAll = document.getElementById('selectAllCheckboxes');
selectAll.addEventListener('change', function() {
document.querySelectorAll('.scrobble-checkbox').forEach(function(cb) {
cb.checked = selectAll.checked;
});
});
}
function cancelRemoveMode() {
var checkboxes = document.querySelectorAll('.remove-checkbox-col');
checkboxes.forEach(function(col) {
col.style.display = 'none';
});
document.getElementById('removeScrobblesBtn').style.display = '';
document.getElementById('removeControls').style.display = 'none';
document.querySelectorAll('.scrobble-checkbox').forEach(function(cb) {
cb.checked = false;
});
document.getElementById('selectAllCheckboxes').checked = false;
}
function deleteSelectedScrobbles() {
var checkboxes = document.querySelectorAll('.scrobble-checkbox:checked');
var ids = [];
checkboxes.forEach(function(cb) {
ids.push(parseInt(cb.value));
});
if (ids.length === 0) {
alert('Please select at least one scrobble to delete.');
return;
}
if (!confirm('Are you sure you want to delete ' + ids.length + ' scrobble(s)?')) {
return;
}
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/scrobble/delete', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
location.reload();
} else {
alert('Error deleting scrobbles: ' + xhr.responseText);
}
}
};
xhr.send(JSON.stringify(ids));
}
</script>
{{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)
@@ -641,8 +735,15 @@ func songBatchEditHandler() http.HandlerFunc {
return
}
artist, _ := db.GetArtistById(song.ArtistId)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"success": "true"})
json.NewEncoder(w).Encode(map[string]string{
"success": "true",
"artist": artist.Name,
"title": title,
"username": username,
})
}
}
@@ -763,7 +864,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 +895,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 +915,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 +927,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 +982,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
@@ -896,3 +1009,36 @@ func imageUploadHandler() http.HandlerFunc {
})
}
}
func deleteScrobbleHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
var ids []int
if err := json.NewDecoder(r.Body).Decode(&ids); err != nil {
fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
err = db.DeleteHistoryByIds(userId, ids)
if err != nil {
fmt.Fprintf(os.Stderr, "Error deleting scrobbles: %v\n", err)
http.Error(w, "Error deleting scrobbles", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
}

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
@@ -32,6 +34,17 @@ type ProfileData struct {
TemplateName string
NowPlayingArtist string
NowPlayingTitle string
TopArtists []db.TopArtist
TopArtistsPeriod string
TopArtistsLimit int
TopArtistsView string
TopAlbums []db.TopAlbum
TopAlbumsPeriod string
TopAlbumsLimit int
TopAlbumsView string
TopTracks []db.TopTrack
TopTracksPeriod string
TopTracksLimit int
}
// Render a page of the profile in the URL
@@ -72,16 +85,206 @@ 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)
return
}
period := r.URL.Query().Get("period")
if period == "" {
period = "all_time"
}
view := r.URL.Query().Get("view")
if view == "" {
view = "grid"
}
maxLimit := 30
if view == "grid" {
maxLimit = 8
}
limitStr := r.URL.Query().Get("limit")
limit := 10
if limitStr != "" {
limit, err = strconv.Atoi(limitStr)
if err != nil || limit < 5 {
limit = 10
}
if limit > maxLimit {
limit = maxLimit
}
}
profileData.TopArtistsPeriod = period
profileData.TopArtistsLimit = limit
profileData.TopArtistsView = view
var startDate, endDate *time.Time
now := time.Now()
switch period {
case "week":
start := now.AddDate(0, 0, -7)
startDate = &start
case "month":
start := now.AddDate(0, -1, 0)
startDate = &start
case "year":
start := now.AddDate(-1, 0, 0)
startDate = &start
case "custom":
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
if startStr != "" {
if t, err := time.Parse("2006-01-02", startStr); err == nil {
startDate = &t
}
}
if endStr != "" {
if t, err := time.Parse("2006-01-02", endStr); err == nil {
t = t.AddDate(0, 0, 1)
endDate = &t
}
}
}
topArtists, err := db.GetTopArtists(userId, limit, startDate, endDate)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get top artists: %v\n", err)
} else {
profileData.TopArtists = topArtists
}
albumPeriod := r.URL.Query().Get("album_period")
if albumPeriod == "" {
albumPeriod = "all_time"
}
var albumStartDate, albumEndDate *time.Time
albumNow := time.Now()
switch albumPeriod {
case "week":
start := albumNow.AddDate(0, 0, -7)
albumStartDate = &start
case "month":
start := albumNow.AddDate(0, -1, 0)
albumStartDate = &start
case "year":
start := albumNow.AddDate(-1, 0, 0)
albumStartDate = &start
case "custom":
albumStartStr := r.URL.Query().Get("album_start")
albumEndStr := r.URL.Query().Get("album_end")
if albumStartStr != "" {
if t, err := time.Parse("2006-01-02", albumStartStr); err == nil {
albumStartDate = &t
}
}
if albumEndStr != "" {
if t, err := time.Parse("2006-01-02", albumEndStr); err == nil {
t = t.AddDate(0, 0, 1)
albumEndDate = &t
}
}
}
albumLimitStr := r.URL.Query().Get("album_limit")
albumLimit := 10
if albumLimitStr != "" {
albumLimit, err = strconv.Atoi(albumLimitStr)
if err != nil || albumLimit < 5 {
albumLimit = 10
}
if albumLimit > 30 {
albumLimit = 30
}
}
albumView := r.URL.Query().Get("album_view")
if albumView == "" {
albumView = "grid"
}
albumMaxLimit := 30
if albumView == "grid" {
albumMaxLimit = 8
}
if albumLimit > albumMaxLimit {
albumLimit = albumMaxLimit
}
profileData.TopAlbumsPeriod = albumPeriod
profileData.TopAlbumsLimit = albumLimit
profileData.TopAlbumsView = albumView
topAlbums, err := db.GetTopAlbums(userId, albumLimit, albumStartDate, albumEndDate)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get top albums: %v\n", err)
} else {
profileData.TopAlbums = topAlbums
}
trackPeriod := r.URL.Query().Get("track_period")
if trackPeriod == "" {
trackPeriod = "all_time"
}
var trackStartDate, trackEndDate *time.Time
trackNow := time.Now()
switch trackPeriod {
case "week":
start := trackNow.AddDate(0, 0, -7)
trackStartDate = &start
case "month":
start := trackNow.AddDate(0, -1, 0)
trackStartDate = &start
case "year":
start := trackNow.AddDate(-1, 0, 0)
trackStartDate = &start
case "custom":
trackStartStr := r.URL.Query().Get("track_start")
trackEndStr := r.URL.Query().Get("track_end")
if trackStartStr != "" {
if t, err := time.Parse("2006-01-02", trackStartStr); err == nil {
trackStartDate = &t
}
}
if trackEndStr != "" {
if t, err := time.Parse("2006-01-02", trackEndStr); err == nil {
t = t.AddDate(0, 0, 1)
trackEndDate = &t
}
}
}
trackLimitStr := r.URL.Query().Get("track_limit")
trackLimit := 10
if trackLimitStr != "" {
trackLimit, err = strconv.Atoi(trackLimitStr)
if err != nil || trackLimit < 5 {
trackLimit = 10
}
if trackLimit > 30 {
trackLimit = 30
}
}
profileData.TopTracksPeriod = trackPeriod
profileData.TopTracksLimit = trackLimit
topTracks, err := db.GetTopTracks(userId, trackLimit, trackStartDate, trackEndDate)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get top tracks: %v\n", err)
} else {
profileData.TopTracks = topTracks
}
if pageInt == 1 {
if np, ok := scrobble.GetNowPlaying(userId); ok {
profileData.NowPlayingArtist = np.Artist
@@ -91,7 +294,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 +307,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)
}

183
web/scrobble.go Normal file
View File

@@ -0,0 +1,183 @@
package web
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"muzi/db"
)
type ScrobbleTrack struct {
SongName string `json:"song_name"`
Artist string `json:"artist"`
AlbumName string `json:"album_name"`
Timestamp string `json:"timestamp"`
MsPlayed int `json:"ms_played"`
}
type ScrobbleRequest struct {
Tracks []ScrobbleTrack `json:"tracks"`
}
type scrobbleData struct {
Title string
LoggedInUsername string
TemplateName string
}
func scrobblePageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
data := scrobbleData{
Title: "muzi | Manual Scrobble",
LoggedInUsername: username,
TemplateName: "scrobble",
}
err := templates.ExecuteTemplate(w, "base", data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func scrobbleSubmitHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
var req ScrobbleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if len(req.Tracks) == 0 {
http.Error(w, "No tracks provided", http.StatusBadRequest)
return
}
count, err := insertScrobbles(r.Context(), userId, req.Tracks)
if err != nil {
fmt.Fprintf(os.Stderr, "Error inserting scrobbles: %v\n", err)
http.Error(w, fmt.Sprintf("Error inserting scrobbles: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"count": count,
})
}
}
func insertScrobbles(ctx context.Context, userId int, tracks []ScrobbleTrack) (int, error) {
artistIdMap := make(map[string][]int)
for _, track := range tracks {
if track.Artist == "" || track.SongName == "" {
continue
}
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[track.Artist+"|"+track.SongName] = artistIds
}
imported := 0
for _, track := range tracks {
if track.Artist == "" || track.SongName == "" {
continue
}
timestamp, err := time.Parse(time.RFC3339, track.Timestamp)
if err != nil {
timestamp = time.Now()
}
artistIds := artistIdMap[track.Artist+"|"+track.SongName]
var artistId int
if len(artistIds) > 0 {
artistId = artistIds[0]
}
var albumId int
if track.AlbumName != "" && artistId > 0 {
albumId, _, _ = db.GetOrCreateAlbum(userId, track.AlbumName, artistId)
}
var songId int
if track.SongName != "" && artistId > 0 {
songId, _, _ = db.GetOrCreateSong(userId, track.SongName, artistId, albumId)
}
var albumNamePg *string
if track.AlbumName != "" {
albumNamePg = &track.AlbumName
}
_, err = db.Pool.Exec(ctx,
`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`,
userId, timestamp, track.SongName, track.Artist, albumNamePg, track.MsPlayed, "manual", artistId, songId, artistIds,
)
if err != nil {
if !strings.Contains(err.Error(), "duplicate") {
fmt.Fprintf(os.Stderr, "Error inserting scrobble: %v\n", err)
}
continue
}
imported++
}
return imported, 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
}

View File

@@ -76,3 +76,19 @@ func getUserIdByUsername(ctx context.Context, username string) (int, error) {
Scan(&userId)
return userId, err
}
func logoutHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err == nil {
deleteSession(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
}

View File

@@ -5,6 +5,8 @@ package web
import (
"fmt"
"time"
"muzi/db"
)
// Subtracts two integers
@@ -17,6 +19,69 @@ func add(a int, b int) int {
return a + b
}
// Divides two integers (integer division)
func div(a int, b int) int {
if b == 0 {
return 0
}
return a / b
}
// Returns a % b
func mod(a int, b int) int {
return a % b
}
// Returns a slice of a slice from start to end
func slice(a []db.TopArtist, start int, end int) []db.TopArtist {
if start >= len(a) {
return []db.TopArtist{}
}
if end > len(a) {
end = len(a)
}
return a[start:end]
}
func sliceAlbum(a []db.TopAlbum, start int, end int) []db.TopAlbum {
if start >= len(a) {
return []db.TopAlbum{}
}
if end > len(a) {
end = len(a)
}
return a[start:end]
}
func sliceTrack(a []db.TopTrack, start int, end int) []db.TopTrack {
if start >= len(a) {
return []db.TopTrack{}
}
if end > len(a) {
end = len(a)
}
return a[start:end]
}
func gridReorder(artists []db.TopArtist) []db.TopArtist {
if len(artists) < 2 {
return artists
}
if len(artists)%2 == 0 {
return artists
}
remaining := len(artists) - 1
perRow := remaining / 2
rest := artists[1:]
firstRow := rest[:perRow]
secondRow := rest[perRow:]
var reordered []db.TopArtist
reordered = append(reordered, artists[0])
reordered = append(reordered, secondRow...)
reordered = append(reordered, firstRow...)
return reordered
}
// Put a comma in the thousands place, ten-thousands place etc.
func formatInt(n int) string {
if n < 1000 {
@@ -56,3 +121,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

@@ -33,10 +33,17 @@ func init() {
funcMap := template.FuncMap{
"sub": sub,
"add": add,
"div": div,
"mod": mod,
"slice": slice,
"sliceAlbum": sliceAlbum,
"sliceTrack": sliceTrack,
"gridReorder": gridReorder,
"formatInt": formatInt,
"formatTimestamp": formatTimestamp,
"formatTimestampFull": formatTimestampFull,
"urlquery": url.QueryEscape,
"getArtistNames": GetArtistNames,
}
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
}
@@ -81,6 +88,7 @@ func Start() {
r.Handle("/files/*", http.StripPrefix("/files", http.FileServer(http.Dir("./static"))))
r.Get("/", rootHandler())
r.Get("/login", loginPageHandler())
r.Get("/logout", logoutHandler())
r.Get("/createaccount", createAccountPageHandler())
r.Get("/profile/{username}", profilePageHandler())
r.Get("/profile/{username}/artist/{artist}", artistPageHandler())
@@ -112,6 +120,7 @@ func Start() {
r.Patch("/api/artist/{id}/batch", artistBatchEditHandler())
r.Patch("/api/song/{id}/batch", songBatchEditHandler())
r.Patch("/api/album/{id}/batch", albumBatchEditHandler())
r.Post("/api/scrobble/delete", deleteScrobbleHandler())
r.Post("/api/upload/image", imageUploadHandler())
r.Get("/search", searchHandler())
r.Get("/import", importPageHandler())
@@ -121,6 +130,8 @@ func Start() {
r.Post("/import/spotify", importSpotifyHandler)
r.Get("/import/lastfm/progress", importLastFMProgressHandler)
r.Get("/import/spotify/progress", importSpotifyProgressHandler)
r.Get("/scrobble", scrobblePageHandler())
r.Post("/scrobble", scrobbleSubmitHandler())
r.Handle("/2.0", scrobble.NewLastFMHandler())
r.Handle("/2.0/", scrobble.NewLastFMHandler())