mirror of
https://github.com/riwiwa/muzi.git
synced 2026-04-16 09:25:50 -07:00
Compare commits
15 Commits
09ac8b7fb0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ace5205724 | |||
| f7baf2ee40 | |||
| 0dbbaf38ad | |||
| a9d048a633 | |||
| 56475df1a0 | |||
| 6e0e53eb64 | |||
|
|
24fb1331b4 | ||
| 369aae818c | |||
| d73ae51b95 | |||
| 1b6ff0c283 | |||
| 582d3acbc0 | |||
| 7d70d9ea0f | |||
|
|
181316c343 | ||
| 19ab88268e | |||
| 99185499b1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
muzi
|
muzi
|
||||||
|
static/uploads/
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
- Daily, weekly, monthly, yearly, lifetime presets for listening reports
|
- Daily, weekly, monthly, yearly, lifetime presets for listening reports
|
||||||
- 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 image
|
- 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
|
||||||
|
|||||||
109
db/db.go
109
db/db.go
@@ -14,6 +14,9 @@ import (
|
|||||||
var Pool *pgxpool.Pool
|
var Pool *pgxpool.Pool
|
||||||
|
|
||||||
func CreateAllTables() error {
|
func CreateAllTables() error {
|
||||||
|
if err := CreateExtensions(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := CreateHistoryTable(); err != nil {
|
if err := CreateHistoryTable(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -23,7 +26,32 @@ func CreateAllTables() error {
|
|||||||
if err := CreateSessionsTable(); err != nil {
|
if err := CreateSessionsTable(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return CreateSpotifyLastTrackTable()
|
if err := CreateSpotifyLastTrackTable(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := CreateArtistsTable(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := CreateAlbumsTable(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := CreateSongsTable(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := AddHistoryEntityColumns(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateExtensions() error {
|
||||||
|
_, err := Pool.Exec(context.Background(),
|
||||||
|
"CREATE EXTENSION IF NOT EXISTS pg_trgm;")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating pg_trgm extension: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetDbUrl(dbName bool) string {
|
func GetDbUrl(dbName bool) string {
|
||||||
@@ -153,3 +181,82 @@ func CreateSpotifyLastTrackTable() error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateArtistsTable() error {
|
||||||
|
_, err := Pool.Exec(context.Background(),
|
||||||
|
`CREATE TABLE IF NOT EXISTS artists (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(pk) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
image_url TEXT,
|
||||||
|
bio TEXT,
|
||||||
|
spotify_id TEXT,
|
||||||
|
musicbrainz_id TEXT,
|
||||||
|
UNIQUE (user_id, name)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artists_user_name ON artists(user_id, name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_artists_user_name_trgm ON artists USING gin(name gin_trgm_ops);`)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating artists table: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateAlbumsTable() error {
|
||||||
|
_, err := Pool.Exec(context.Background(),
|
||||||
|
`CREATE TABLE IF NOT EXISTS albums (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(pk) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist_id INTEGER REFERENCES artists(id) ON DELETE SET NULL,
|
||||||
|
cover_url TEXT,
|
||||||
|
spotify_id TEXT,
|
||||||
|
musicbrainz_id TEXT,
|
||||||
|
UNIQUE (user_id, title, artist_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_albums_user_title ON albums(user_id, title);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_albums_user_title_trgm ON albums USING gin(title gin_trgm_ops);`)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating albums table: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateSongsTable() error {
|
||||||
|
_, err := Pool.Exec(context.Background(),
|
||||||
|
`CREATE TABLE IF NOT EXISTS songs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(pk) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist_id INTEGER REFERENCES artists(id) ON DELETE SET NULL,
|
||||||
|
album_id INTEGER REFERENCES albums(id) ON DELETE SET NULL,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
spotify_id TEXT,
|
||||||
|
musicbrainz_id TEXT,
|
||||||
|
UNIQUE (user_id, title, artist_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_songs_user_title ON songs(user_id, title);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_songs_user_title_trgm ON songs USING gin(title gin_trgm_ops);`)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating songs table: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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_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
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
1003
db/entities.go
Normal file
1003
db/entities.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,12 +119,38 @@ func SaveScrobble(scrobble Scrobble) error {
|
|||||||
return fmt.Errorf("duplicate scrobble")
|
return fmt.Errorf("duplicate scrobble")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
artistNames := parseArtistString(scrobble.Artist)
|
||||||
|
artistIds, err := getOrCreateArtists(scrobble.UserId, artistNames)
|
||||||
|
if err != nil {
|
||||||
|
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, 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, primaryArtistId, albumId)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting/creating song: %v\n", 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)
|
`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)
|
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)
|
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
|
||||||
@@ -131,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
|
||||||
|
|||||||
4
static/assets/icons/add.svg
Normal file
4
static/assets/icons/add.svg
Normal 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 |
11
static/assets/icons/logout.svg
Normal file
11
static/assets/icons/logout.svg
Normal 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 |
BIN
static/assets/pfps/default_album.png
Normal file
BIN
static/assets/pfps/default_album.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
BIN
static/assets/pfps/default_artist.png
Normal file
BIN
static/assets/pfps/default_artist.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
165
static/menu.js
165
static/menu.js
@@ -23,10 +23,173 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
menuOverlay.addEventListener('click', closeMenu);
|
menuOverlay.addEventListener('click', closeMenu);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close menu on escape key
|
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
|
closeEditModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Global Search
|
||||||
|
const searchInput = document.getElementById('globalSearch');
|
||||||
|
const searchResults = document.getElementById('searchResults');
|
||||||
|
let searchTimeout;
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', function(e) {
|
||||||
|
const query = e.target.value.trim();
|
||||||
|
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
|
||||||
|
if (query.length < 1) {
|
||||||
|
searchResults.classList.remove('active');
|
||||||
|
searchResults.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeout = setTimeout(function() {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', '/search?q=' + encodeURIComponent(query), true);
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
var results = JSON.parse(xhr.responseText);
|
||||||
|
if (results.length === 0) {
|
||||||
|
searchResults.innerHTML = '<div class="search-result-item"><span class="search-result-name">No results</span></div>';
|
||||||
|
} else {
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < results.length; i++) {
|
||||||
|
var r = results[i];
|
||||||
|
html += '<a href="' + r.url + '" class="search-result-item">' +
|
||||||
|
'<div class="search-result-info">' +
|
||||||
|
'<span class="search-result-name">' + r.name + '</span>' +
|
||||||
|
'<span class="search-result-type">' + r.type + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<span class="search-result-count">' + r.count + '</span>' +
|
||||||
|
'</a>';
|
||||||
|
}
|
||||||
|
searchResults.innerHTML = html;
|
||||||
|
}
|
||||||
|
searchResults.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
searchResults.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
|
||||||
|
searchResults.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image Upload Functionality
|
||||||
|
document.querySelectorAll('.editable-image').forEach(function(img) {
|
||||||
|
img.style.cursor = 'pointer';
|
||||||
|
img.addEventListener('click', function(e) {
|
||||||
|
var entityType = this.getAttribute('data-entity');
|
||||||
|
var entityId = this.getAttribute('data-id');
|
||||||
|
var field = this.getAttribute('data-field');
|
||||||
|
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||||
|
input.onchange = function(e) {
|
||||||
|
var file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('File exceeds 5MB limit');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/api/upload/image', true);
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
var result = JSON.parse(xhr.responseText);
|
||||||
|
|
||||||
|
var patchXhr = new XMLHttpRequest();
|
||||||
|
patchXhr.open('PATCH', '/api/' + entityType + '/' + entityId + '/edit?field=' + field, true);
|
||||||
|
patchXhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
patchXhr.onreadystatechange = function() {
|
||||||
|
if (patchXhr.readyState === 4) {
|
||||||
|
if (patchXhr.status === 200) {
|
||||||
|
img.src = result.url;
|
||||||
|
} else {
|
||||||
|
alert('Error updating image: ' + patchXhr.responseText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
patchXhr.send(JSON.stringify({ value: result.url }));
|
||||||
|
} else {
|
||||||
|
alert('Error uploading: ' + xhr.responseText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(formData);
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generic edit form handler
|
||||||
|
var editForm = document.getElementById('editForm');
|
||||||
|
if (editForm) {
|
||||||
|
editForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var form = e.target;
|
||||||
|
var entityType = form.getAttribute('data-entity');
|
||||||
|
var entityId = form.getAttribute('data-id');
|
||||||
|
|
||||||
|
var data = {};
|
||||||
|
var elements = form.querySelectorAll('input, textarea');
|
||||||
|
for (var i = 0; i < elements.length; i++) {
|
||||||
|
var el = elements[i];
|
||||||
|
if (el.name) {
|
||||||
|
data[el.name] = el.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('PATCH', '/api/' + entityType + '/' + entityId + '/batch', true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(JSON.stringify(data));
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function openEditModal() {
|
||||||
|
document.getElementById('editModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
document.getElementById('editModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|||||||
133
static/profile.js
Normal file
133
static/profile.js
Normal 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();
|
||||||
|
});
|
||||||
790
static/style.css
790
static/style.css
@@ -112,6 +112,93 @@
|
|||||||
filter: invert(1) brightness(1.5);
|
filter: invert(1) brightness(1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global Search */
|
||||||
|
.search-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1001;
|
||||||
|
width: 300px;
|
||||||
|
background: #222;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 25px;
|
||||||
|
background: #333;
|
||||||
|
color: #AFA;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container input:focus {
|
||||||
|
border-color: #AFA;
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 5px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1002;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-name {
|
||||||
|
color: #FFF;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-type {
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-count {
|
||||||
|
color: #AFA;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Menu Overlay */
|
/* Menu Overlay */
|
||||||
.menu-overlay {
|
.menu-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -124,11 +211,13 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-overlay.active {
|
.menu-overlay.active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page_buttons {
|
.page_buttons {
|
||||||
@@ -176,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;
|
||||||
@@ -185,11 +275,32 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
h2 a {
|
||||||
|
color: #AFA;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
h2 a:hover {
|
||||||
|
color: #FFF;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
img {
|
img {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,10 +334,20 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
a {
|
||||||
|
color: #AFA;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #FFF;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-section {
|
.import-section {
|
||||||
@@ -454,3 +575,672 @@ a.button {
|
|||||||
a.button:hover {
|
a.button:hover {
|
||||||
background: #1ed760;
|
background: #1ed760;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin-left: 8px;
|
||||||
|
color: #888;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-toggle:hover {
|
||||||
|
color: #AFA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-form {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-form input,
|
||||||
|
.inline-edit-form textarea {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #333;
|
||||||
|
color: #AFA;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-form textarea {
|
||||||
|
min-width: 200px;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-form button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: #1DB954;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-edit-form button:hover {
|
||||||
|
background: #1ed760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-image {
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-image:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-cover {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
margin-left: 15px;
|
||||||
|
padding: 5px 15px;
|
||||||
|
background: #444;
|
||||||
|
color: #AFA;
|
||||||
|
border: 1px solid #AFA;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 15px;
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: left;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content input,
|
||||||
|
.modal-content textarea {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #333;
|
||||||
|
color: #AFA;
|
||||||
|
font-size: 1em;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-buttons button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-buttons button[type="submit"] {
|
||||||
|
background: #1DB954;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-buttons button[type="submit"]:hover {
|
||||||
|
background: #1ed760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-buttons .cancel-btn {
|
||||||
|
background: #444;
|
||||||
|
color: #AFA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-buttons .cancel-btn:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-box {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-box h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
border-bottom: 1px solid #444;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-box p {
|
||||||
|
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
73
templates/album.gohtml
Normal file
73
templates/album.gohtml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{{define "album"}}
|
||||||
|
<div class="profile-top">
|
||||||
|
{{if .Album.CoverUrl}}
|
||||||
|
<img class="editable-image album-cover" data-entity="album" data-id="{{.Album.Id}}" data-field="cover_url" src="{{.Album.CoverUrl}}" alt="{{.Album.Title}}'s cover">
|
||||||
|
{{else}}
|
||||||
|
<img class="editable-image album-cover" data-entity="album" data-id="{{.Album.Id}}" data-field="cover_url" src="/files/assets/pfps/default_album.png" alt="{{.Album.Title}}'s cover">
|
||||||
|
{{end}}
|
||||||
|
<div class="username-bio">
|
||||||
|
<h1>
|
||||||
|
{{.Album.Title}}
|
||||||
|
{{if eq .LoggedInUsername .Username}}
|
||||||
|
<button class="edit-btn" onclick="openEditModal()">Edit</button>
|
||||||
|
{{end}}
|
||||||
|
</h1>
|
||||||
|
{{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">
|
||||||
|
</div>
|
||||||
|
<div class="user-stats-top">
|
||||||
|
<h3>{{formatInt .ListenCount}}</h3> <p>Listens<p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="history">
|
||||||
|
<h3>Scrobbles</h3>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
{{$username := .Username}}
|
||||||
|
{{range .Times}}
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="page_buttons">
|
||||||
|
{{if gt .Page 1 }}
|
||||||
|
<a href="/profile/{{.Username}}/album/{{urlquery .Artist.Name}}/{{urlquery .Album.Title}}?page={{sub .Page 1}}">Prev Page</a>
|
||||||
|
{{end}}
|
||||||
|
<a href="/profile/{{.Username}}/album/{{urlquery .Artist.Name}}/{{urlquery .Album.Title}}?page={{add .Page 1}}">Next Page</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if eq .LoggedInUsername .Username}}
|
||||||
|
<div id="editModal" class="modal-overlay" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Edit Album</h2>
|
||||||
|
<form id="editForm" data-entity="album" data-id="{{.Album.Id}}">
|
||||||
|
<label>Title: <input type="text" name="title" value="{{.Album.Title}}"></label>
|
||||||
|
<label>Spotify ID: <input type="text" name="spotify_id" value="{{.Album.SpotifyId}}"></label>
|
||||||
|
<label>MusicBrainz ID: <input type="text" name="musicbrainz_id" value="{{.Album.MusicbrainzId}}"></label>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button type="button" class="cancel-btn" onclick="closeEditModal()">Cancel</button>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
73
templates/artist.gohtml
Normal file
73
templates/artist.gohtml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{{define "artist"}}
|
||||||
|
<div class="profile-top">
|
||||||
|
{{if .Artist.ImageUrl}}
|
||||||
|
<img class="editable-image" data-entity="artist" data-id="{{.Artist.Id}}" data-field="image_url" src="{{.Artist.ImageUrl}}" alt="{{.Artist.Name}}'s image">
|
||||||
|
{{else}}
|
||||||
|
<img class="editable-image" data-entity="artist" data-id="{{.Artist.Id}}" data-field="image_url" src="/files/assets/pfps/default_artist.png" alt="{{.Artist.Name}}'s image">
|
||||||
|
{{end}}
|
||||||
|
<div class="username-bio">
|
||||||
|
<h1>
|
||||||
|
{{.Artist.Name}}
|
||||||
|
{{if eq .LoggedInUsername .Username}}
|
||||||
|
<button class="edit-btn" onclick="openEditModal()">Edit</button>
|
||||||
|
{{end}}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="profile-top-blank">
|
||||||
|
</div>
|
||||||
|
<div class="user-stats-top">
|
||||||
|
<h3>{{formatInt .ListenCount}}</h3> <p>Listens<p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="history">
|
||||||
|
<h3>Scrobbles</h3>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
{{$username := .Username}}
|
||||||
|
{{range .Times}}
|
||||||
|
<tr>
|
||||||
|
<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 .ArtistName}}/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td>
|
||||||
|
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="page_buttons">
|
||||||
|
{{if gt .Page 1 }}
|
||||||
|
<a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}?page={{sub .Page 1}}">Prev Page</a>
|
||||||
|
{{end}}
|
||||||
|
<a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}?page={{add .Page 1}}">Next Page</a>
|
||||||
|
</div>
|
||||||
|
<div class="bio-box">
|
||||||
|
<h3>Bio</h3>
|
||||||
|
<p id="bio-display">{{.Artist.Bio}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if eq .LoggedInUsername .Username}}
|
||||||
|
<div id="editModal" class="modal-overlay" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Edit Artist</h2>
|
||||||
|
<form id="editForm" data-entity="artist" data-id="{{.Artist.Id}}">
|
||||||
|
<label>Name: <input type="text" name="name" value="{{.Artist.Name}}"></label>
|
||||||
|
<label>Bio: <textarea name="bio">{{.Artist.Bio}}</textarea></label>
|
||||||
|
<label>Spotify ID: <input type="text" name="spotify_id" value="{{.Artist.SpotifyId}}"></label>
|
||||||
|
<label>MusicBrainz ID: <input type="text" name="musicbrainz_id" value="{{.Artist.MusicbrainzId}}"></label>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button type="button" class="cancel-btn" onclick="closeEditModal()">Cancel</button>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -14,6 +14,12 @@
|
|||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="search-container">
|
||||||
|
<input type="text" id="globalSearch" placeholder="Search artists, songs, albums..." autocomplete="off">
|
||||||
|
<div id="searchResults" class="search-results"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Slide-out Menu -->
|
<!-- Slide-out Menu -->
|
||||||
<div class="side-menu" id="sideMenu">
|
<div class="side-menu" id="sideMenu">
|
||||||
<div class="menu-header">
|
<div class="menu-header">
|
||||||
@@ -25,6 +31,10 @@
|
|||||||
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Profile">
|
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Profile">
|
||||||
<span>My Profile</span>
|
<span>My Profile</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/logout" class="menu-item">
|
||||||
|
<img src="/files/assets/icons/logout.svg" class="menu-icon" alt="Logout">
|
||||||
|
<span>Logout</span>
|
||||||
|
</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<a href="/login" class="menu-item">
|
<a href="/login" class="menu-item">
|
||||||
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Login">
|
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Login">
|
||||||
@@ -35,6 +45,10 @@
|
|||||||
<img src="/files/assets/icons/settings.svg" class="menu-icon" alt="Settings">
|
<img src="/files/assets/icons/settings.svg" class="menu-icon" alt="Settings">
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,8 +58,15 @@
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
{{ if eq .TemplateName "profile"}}{{block "profile" .}}{{end}}{{end}}
|
{{ if eq .TemplateName "profile"}}{{block "profile" .}}{{end}}{{end}}
|
||||||
{{ if eq .TemplateName "settings"}}{{block "settings" .}}{{end}}{{end}}
|
{{ if eq .TemplateName "settings"}}{{block "settings" .}}{{end}}{{end}}
|
||||||
|
{{ 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>
|
<script src="/files/menu.js"></script>
|
||||||
|
{{if eq .TemplateName "profile"}}
|
||||||
|
<script src="/files/profile.js"></script>
|
||||||
|
{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -9,9 +9,352 @@
|
|||||||
</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>
|
||||||
|
<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">
|
<div class="history">
|
||||||
<h3>Listening History</h3>
|
<h3>Listening History</h3>
|
||||||
<table>
|
<table>
|
||||||
@@ -27,13 +370,18 @@
|
|||||||
<td>Now Playing</td>
|
<td>Now Playing</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{$artists := .Artists}}
|
{{$artistIdsList := .ArtistIdsList}}
|
||||||
{{$times := .Times}}
|
{{$times := .Times}}
|
||||||
|
{{$username := .Username}}
|
||||||
{{range $index, $title := .Titles}}
|
{{range $index, $title := .Titles}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{index $artists $index}}</td>
|
<td>
|
||||||
<td>{{$title}}</td>
|
{{- $artistIds := index $artistIdsList $index}}
|
||||||
<td title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</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 (index $.Artists $index)}}/{{urlquery $title}}">{{$title}}</a></td>
|
||||||
|
<td><span title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
148
templates/scrobble.gohtml
Normal file
148
templates/scrobble.gohtml
Normal 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;">×</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">×</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;">×</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
updateRemoveButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRemoveButtons();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
145
templates/song.gohtml
Normal file
145
templates/song.gohtml
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
{{define "song"}}
|
||||||
|
<div class="profile-top">
|
||||||
|
<div class="username-bio">
|
||||||
|
<h1>
|
||||||
|
{{.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 .ArtistNames}}
|
||||||
|
<h2>
|
||||||
|
{{- range $i, $name := .ArtistNames}}{{if $i}}, {{end}}<a href="/profile/{{$.Username}}/artist/{{urlquery $name}}">{{$name}}</a>{{end}}
|
||||||
|
</h2>
|
||||||
|
{{end}}
|
||||||
|
{{range .Albums}}
|
||||||
|
<h3><a href="/profile/{{$.Username}}/album/{{urlquery $.Artist.Name}}/{{urlquery .Title}}">{{.Title}}</a></h3>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="profile-top-blank">
|
||||||
|
</div>
|
||||||
|
<div class="user-stats-top">
|
||||||
|
<h3>{{formatInt .ListenCount}}</h3> <p>Listens<p>
|
||||||
|
</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>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
{{$username := .Username}}
|
||||||
|
{{range .Times}}
|
||||||
|
<tr>
|
||||||
|
<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 .ArtistName}}/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td>
|
||||||
|
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="page_buttons">
|
||||||
|
{{if gt .Page 1 }}
|
||||||
|
<a href="/profile/{{.Username}}/song/{{urlquery .Artist.Name}}/{{urlquery .Song.Title}}?page={{sub .Page 1}}">Prev Page</a>
|
||||||
|
{{end}}
|
||||||
|
<a href="/profile/{{.Username}}/song/{{urlquery .Artist.Name}}/{{urlquery .Song.Title}}?page={{add .Page 1}}">Next Page</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if eq .LoggedInUsername .Username}}
|
||||||
|
<div id="editModal" class="modal-overlay" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Edit Song</h2>
|
||||||
|
<form id="editForm" data-entity="song" data-id="{{.Song.Id}}">
|
||||||
|
<label>Title: <input type="text" name="title" value="{{.Song.Title}}"></label>
|
||||||
|
<label>Spotify ID: <input type="text" name="spotify_id" value="{{.Song.SpotifyId}}"></label>
|
||||||
|
<label>MusicBrainz ID: <input type="text" name="musicbrainz_id" value="{{.Song.MusicbrainzId}}"></label>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button type="button" class="cancel-btn" onclick="closeEditModal()">Cancel</button>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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}}
|
||||||
1044
web/entity.go
Normal file
1044
web/entity.go
Normal file
File diff suppressed because it is too large
Load Diff
225
web/profile.go
225
web/profile.go
@@ -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
|
||||||
@@ -32,6 +34,17 @@ type ProfileData struct {
|
|||||||
TemplateName string
|
TemplateName string
|
||||||
NowPlayingArtist string
|
NowPlayingArtist string
|
||||||
NowPlayingTitle 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
|
// Render a page of the profile in the URL
|
||||||
@@ -72,16 +85,206 @@ 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)
|
||||||
return
|
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 pageInt == 1 {
|
||||||
if np, ok := scrobble.GetNowPlaying(userId); ok {
|
if np, ok := scrobble.GetNowPlaying(userId); ok {
|
||||||
profileData.NowPlayingArtist = np.Artist
|
profileData.NowPlayingArtist = np.Artist
|
||||||
@@ -91,7 +294,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 +307,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)
|
||||||
}
|
}
|
||||||
|
|||||||
183
web/scrobble.go
Normal file
183
web/scrobble.go
Normal 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
|
||||||
|
}
|
||||||
@@ -76,3 +76,19 @@ func getUserIdByUsername(ctx context.Context, username string) (int, error) {
|
|||||||
Scan(&userId)
|
Scan(&userId)
|
||||||
return userId, err
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
80
web/utils.go
80
web/utils.go
@@ -5,6 +5,8 @@ package web
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"muzi/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Subtracts two integers
|
// Subtracts two integers
|
||||||
@@ -17,6 +19,69 @@ func add(a int, b int) int {
|
|||||||
return a + b
|
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.
|
// Put a comma in the thousands place, ten-thousands place etc.
|
||||||
func formatInt(n int) string {
|
func formatInt(n int) string {
|
||||||
if n < 1000 {
|
if n < 1000 {
|
||||||
@@ -56,3 +121,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
|
||||||
|
}
|
||||||
|
|||||||
44
web/web.go
44
web/web.go
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"muzi/config"
|
"muzi/config"
|
||||||
@@ -32,9 +33,17 @@ func init() {
|
|||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"sub": sub,
|
"sub": sub,
|
||||||
"add": add,
|
"add": add,
|
||||||
|
"div": div,
|
||||||
|
"mod": mod,
|
||||||
|
"slice": slice,
|
||||||
|
"sliceAlbum": sliceAlbum,
|
||||||
|
"sliceTrack": sliceTrack,
|
||||||
|
"gridReorder": gridReorder,
|
||||||
"formatInt": formatInt,
|
"formatInt": formatInt,
|
||||||
"formatTimestamp": formatTimestamp,
|
"formatTimestamp": formatTimestamp,
|
||||||
"formatTimestampFull": formatTimestampFull,
|
"formatTimestampFull": formatTimestampFull,
|
||||||
|
"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"))
|
||||||
}
|
}
|
||||||
@@ -79,8 +88,41 @@ func Start() {
|
|||||||
r.Handle("/files/*", http.StripPrefix("/files", http.FileServer(http.Dir("./static"))))
|
r.Handle("/files/*", http.StripPrefix("/files", http.FileServer(http.Dir("./static"))))
|
||||||
r.Get("/", rootHandler())
|
r.Get("/", rootHandler())
|
||||||
r.Get("/login", loginPageHandler())
|
r.Get("/login", loginPageHandler())
|
||||||
|
r.Get("/logout", logoutHandler())
|
||||||
r.Get("/createaccount", createAccountPageHandler())
|
r.Get("/createaccount", createAccountPageHandler())
|
||||||
r.Get("/profile/{username}", profilePageHandler())
|
r.Get("/profile/{username}", profilePageHandler())
|
||||||
|
r.Get("/profile/{username}/artist/{artist}", artistPageHandler())
|
||||||
|
r.Get("/profile/{username}/song/{artist}/{song}", songPageHandler())
|
||||||
|
r.Get("/profile/{username}/album/{artist}/{album}", albumPageHandler())
|
||||||
|
r.Get("/profile/{username}/album/{album}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := chi.URLParam(r, "username")
|
||||||
|
albumTitle, _ := url.QueryUnescape(chi.URLParam(r, "album"))
|
||||||
|
userId, err := getUserIdByUsername(r.Context(), username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "User not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
albums, _, _ := db.SearchAlbums(userId, albumTitle)
|
||||||
|
if len(albums) > 0 {
|
||||||
|
album := albums[0]
|
||||||
|
artist, _ := db.GetArtistById(album.ArtistId)
|
||||||
|
http.Redirect(w, r, "/profile/"+username+"/album/"+url.QueryEscape(artist.Name)+"/"+url.QueryEscape(album.Title), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Album not found", http.StatusNotFound)
|
||||||
|
})
|
||||||
|
r.Post("/profile/{username}/artist/{id}/edit", editArtistHandler())
|
||||||
|
r.Post("/profile/{username}/song/{id}/edit", editSongHandler())
|
||||||
|
r.Post("/profile/{username}/album/{id}/edit", editAlbumHandler())
|
||||||
|
r.Patch("/api/artist/{id}/edit", artistInlineEditHandler())
|
||||||
|
r.Patch("/api/song/{id}/edit", songInlineEditHandler())
|
||||||
|
r.Patch("/api/album/{id}/edit", albumInlineEditHandler())
|
||||||
|
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())
|
r.Get("/import", importPageHandler())
|
||||||
r.Post("/loginsubmit", loginSubmit)
|
r.Post("/loginsubmit", loginSubmit)
|
||||||
r.Post("/createaccountsubmit", createAccount)
|
r.Post("/createaccountsubmit", createAccount)
|
||||||
@@ -88,6 +130,8 @@ func Start() {
|
|||||||
r.Post("/import/spotify", importSpotifyHandler)
|
r.Post("/import/spotify", importSpotifyHandler)
|
||||||
r.Get("/import/lastfm/progress", importLastFMProgressHandler)
|
r.Get("/import/lastfm/progress", importLastFMProgressHandler)
|
||||||
r.Get("/import/spotify/progress", importSpotifyProgressHandler)
|
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())
|
||||||
r.Handle("/2.0/", scrobble.NewLastFMHandler())
|
r.Handle("/2.0/", scrobble.NewLastFMHandler())
|
||||||
|
|||||||
Reference in New Issue
Block a user