mirror of
https://github.com/riwiwa/muzi.git
synced 2026-04-16 09:25:50 -07:00
Compare commits
3 Commits
09ac8b7fb0
...
181316c343
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
- 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
|
||||||
|
|||||||
107
db/db.go
107
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,80 @@ 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;
|
||||||
|
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);`)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error adding history entity columns: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
675
db/entities.go
Normal file
675
db/entities.go
Normal file
@@ -0,0 +1,675 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Artist struct {
|
||||||
|
Id int
|
||||||
|
UserId int
|
||||||
|
Name string
|
||||||
|
ImageUrl string
|
||||||
|
Bio string
|
||||||
|
SpotifyId string
|
||||||
|
MusicbrainzId string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Album struct {
|
||||||
|
Id int
|
||||||
|
UserId int
|
||||||
|
Title string
|
||||||
|
ArtistId int
|
||||||
|
CoverUrl string
|
||||||
|
SpotifyId string
|
||||||
|
MusicbrainzId string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Song struct {
|
||||||
|
Id int
|
||||||
|
UserId int
|
||||||
|
Title string
|
||||||
|
ArtistId int
|
||||||
|
AlbumId int
|
||||||
|
DurationMs int
|
||||||
|
SpotifyId string
|
||||||
|
MusicbrainzId string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOrCreateArtist(userId int, name string) (int, bool, error) {
|
||||||
|
if name == "" {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int
|
||||||
|
err := Pool.QueryRow(context.Background(),
|
||||||
|
"SELECT id FROM artists WHERE user_id = $1 AND name = $2",
|
||||||
|
userId, name).Scan(&id)
|
||||||
|
if err == nil {
|
||||||
|
return id, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = Pool.QueryRow(context.Background(),
|
||||||
|
`INSERT INTO artists (user_id, name) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id, name) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING id`,
|
||||||
|
userId, name).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating artist: %v\n", err)
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
return id, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetArtistById(id int) (Artist, error) {
|
||||||
|
var artist Artist
|
||||||
|
var imageUrlPg, bioPg, spotifyIdPg, musicbrainzIdPg pgtype.Text
|
||||||
|
err := Pool.QueryRow(context.Background(),
|
||||||
|
"SELECT id, user_id, name, image_url, bio, spotify_id, musicbrainz_id FROM artists WHERE id = $1",
|
||||||
|
id).Scan(&artist.Id, &artist.UserId, &artist.Name, &imageUrlPg, &bioPg, &spotifyIdPg, &musicbrainzIdPg)
|
||||||
|
if err != nil {
|
||||||
|
return Artist{}, err
|
||||||
|
}
|
||||||
|
if imageUrlPg.Status == pgtype.Present {
|
||||||
|
artist.ImageUrl = imageUrlPg.String
|
||||||
|
}
|
||||||
|
if bioPg.Status == pgtype.Present {
|
||||||
|
artist.Bio = bioPg.String
|
||||||
|
}
|
||||||
|
if spotifyIdPg.Status == pgtype.Present {
|
||||||
|
artist.SpotifyId = spotifyIdPg.String
|
||||||
|
}
|
||||||
|
if musicbrainzIdPg.Status == pgtype.Present {
|
||||||
|
artist.MusicbrainzId = musicbrainzIdPg.String
|
||||||
|
}
|
||||||
|
return artist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetArtistByName(userId int, name string) (Artist, error) {
|
||||||
|
var artist Artist
|
||||||
|
var imageUrlPg, bioPg, spotifyIdPg, musicbrainzIdPg pgtype.Text
|
||||||
|
err := Pool.QueryRow(context.Background(),
|
||||||
|
"SELECT id, user_id, name, image_url, bio, spotify_id, musicbrainz_id FROM artists WHERE user_id = $1 AND name = $2",
|
||||||
|
userId, name).Scan(&artist.Id, &artist.UserId, &artist.Name, &imageUrlPg, &bioPg, &spotifyIdPg, &musicbrainzIdPg)
|
||||||
|
if err != nil {
|
||||||
|
return Artist{}, err
|
||||||
|
}
|
||||||
|
if imageUrlPg.Status == pgtype.Present {
|
||||||
|
artist.ImageUrl = imageUrlPg.String
|
||||||
|
}
|
||||||
|
if bioPg.Status == pgtype.Present {
|
||||||
|
artist.Bio = bioPg.String
|
||||||
|
}
|
||||||
|
if spotifyIdPg.Status == pgtype.Present {
|
||||||
|
artist.SpotifyId = spotifyIdPg.String
|
||||||
|
}
|
||||||
|
if musicbrainzIdPg.Status == pgtype.Present {
|
||||||
|
artist.MusicbrainzId = musicbrainzIdPg.String
|
||||||
|
}
|
||||||
|
return artist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateArtist(id int, name, imageUrl, bio, spotifyId, musicbrainzId string) error {
|
||||||
|
_, err := Pool.Exec(context.Background(),
|
||||||
|
`UPDATE artists SET name = $1, image_url = $2, bio = $3, spotify_id = $4, musicbrainz_id = $5 WHERE id = $6`,
|
||||||
|
name, imageUrl, bio, spotifyId, musicbrainzId, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchArtists(userId int, query string) ([]Artist, float64, error) {
|
||||||
|
likePattern := "%" + query + "%"
|
||||||
|
rows, err := Pool.Query(context.Background(),
|
||||||
|
`SELECT id, user_id, name, image_url, bio, spotify_id, musicbrainz_id, similarity(name, $2) as sim
|
||||||
|
FROM artists WHERE user_id = $1 AND (similarity(name, $2) > 0.1 OR LOWER(name) LIKE LOWER($3))
|
||||||
|
ORDER BY similarity(name, $2) DESC LIMIT 20`,
|
||||||
|
userId, query, likePattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var artists []Artist
|
||||||
|
var maxSim float64
|
||||||
|
for rows.Next() {
|
||||||
|
var a Artist
|
||||||
|
var imageUrlPg, bioPg, spotifyIdPg, musicbrainzIdPg pgtype.Text
|
||||||
|
var sim float64
|
||||||
|
err := rows.Scan(&a.Id, &a.UserId, &a.Name, &imageUrlPg, &bioPg, &spotifyIdPg, &musicbrainzIdPg, &sim)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if imageUrlPg.Status == pgtype.Present {
|
||||||
|
a.ImageUrl = imageUrlPg.String
|
||||||
|
}
|
||||||
|
if bioPg.Status == pgtype.Present {
|
||||||
|
a.Bio = bioPg.String
|
||||||
|
}
|
||||||
|
if spotifyIdPg.Status == pgtype.Present {
|
||||||
|
a.SpotifyId = spotifyIdPg.String
|
||||||
|
}
|
||||||
|
if musicbrainzIdPg.Status == pgtype.Present {
|
||||||
|
a.MusicbrainzId = musicbrainzIdPg.String
|
||||||
|
}
|
||||||
|
artists = append(artists, a)
|
||||||
|
if sim > maxSim {
|
||||||
|
maxSim = sim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return artists, maxSim, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOrCreateAlbum(userId int, title string, artistId int) (int, bool, error) {
|
||||||
|
if title == "" {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int
|
||||||
|
err := Pool.QueryRow(context.Background(),
|
||||||
|
"SELECT id FROM albums WHERE user_id = $1 AND title = $2 AND (artist_id = $3 OR (artist_id IS NULL AND $3 IS NULL))",
|
||||||
|
userId, title, artistId).Scan(&id)
|
||||||
|
if err == nil {
|
||||||
|
return id, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = Pool.QueryRow(context.Background(),
|
||||||
|
`INSERT INTO albums (user_id, title, artist_id) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, title, artist_id) DO UPDATE SET title = EXCLUDED.title
|
||||||
|
RETURNING id`,
|
||||||
|
userId, title, artistId).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating album: %v\n", err)
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
return id, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAlbumById(id int) (Album, error) {
|
||||||
|
var album Album
|
||||||
|
err := Pool.QueryRow(context.Background(),
|
||||||
|
"SELECT id, user_id, title, artist_id, cover_url, spotify_id, musicbrainz_id FROM albums WHERE id = $1",
|
||||||
|
id).Scan(&album.Id, &album.UserId, &album.Title, &album.ArtistId, &album.CoverUrl,
|
||||||
|
&album.SpotifyId, &album.MusicbrainzId)
|
||||||
|
if err != nil {
|
||||||
|
return Album{}, err
|
||||||
|
}
|
||||||
|
return album, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAlbumByName(userId int, title string, artistId int) (Album, error) {
|
||||||
|
var album Album
|
||||||
|
var artistIdVal int
|
||||||
|
var coverUrlPg, spotifyIdPg, musicbrainzIdPg pgtype.Text
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if artistId > 0 {
|
||||||
|
query = `SELECT id, user_id, title, artist_id, cover_url, spotify_id, musicbrainz_id
|
||||||
|
FROM albums WHERE user_id = $1 AND title = $2 AND artist_id = $3`
|
||||||
|
args = []interface{}{userId, title, artistId}
|
||||||
|
} else {
|
||||||
|
query = `SELECT id, user_id, title, artist_id, cover_url, spotify_id, musicbrainz_id
|
||||||
|
FROM albums WHERE user_id = $1 AND title = $2`
|
||||||
|
args = []interface{}{userId, title}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Pool.QueryRow(context.Background(), query, args...).Scan(
|
||||||
|
&album.Id, &album.UserId, &album.Title, &artistIdVal, &coverUrlPg,
|
||||||
|
&spotifyIdPg, &musicbrainzIdPg)
|
||||||
|
if err != nil {
|
||||||
|
return Album{}, err
|
||||||
|
}
|
||||||
|
album.ArtistId = artistIdVal
|
||||||
|
if coverUrlPg.Status == pgtype.Present {
|
||||||
|
album.CoverUrl = coverUrlPg.String
|
||||||
|
}
|
||||||
|
if spotifyIdPg.Status == pgtype.Present {
|
||||||
|
album.SpotifyId = spotifyIdPg.String
|
||||||
|
}
|
||||||
|
if musicbrainzIdPg.Status == pgtype.Present {
|
||||||
|
album.MusicbrainzId = musicbrainzIdPg.String
|
||||||
|
}
|
||||||
|
return album, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateAlbum(id int, title, coverUrl, spotifyId, musicbrainzId string) error {
|
||||||
|
_, err := Pool.Exec(context.Background(),
|
||||||
|
`UPDATE albums SET
|
||||||
|
title = COALESCE(NULLIF($1, ''), title),
|
||||||
|
cover_url = COALESCE(NULLIF($2, ''), cover_url),
|
||||||
|
spotify_id = COALESCE(NULLIF($3, ''), spotify_id),
|
||||||
|
musicbrainz_id = COALESCE(NULLIF($4, ''), musicbrainz_id)
|
||||||
|
WHERE id = $5`,
|
||||||
|
title, coverUrl, spotifyId, musicbrainzId, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateAlbumField(id int, field string, value string) error {
|
||||||
|
var query string
|
||||||
|
switch field {
|
||||||
|
case "title":
|
||||||
|
query = "UPDATE albums SET title = $1 WHERE id = $2"
|
||||||
|
case "cover_url":
|
||||||
|
query = "UPDATE albums SET cover_url = $1 WHERE id = $2"
|
||||||
|
case "spotify_id":
|
||||||
|
query = "UPDATE albums SET spotify_id = $1 WHERE id = $2"
|
||||||
|
case "musicbrainz_id":
|
||||||
|
query = "UPDATE albums SET musicbrainz_id = $1 WHERE id = $2"
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown field: %s", field)
|
||||||
|
}
|
||||||
|
_, err := Pool.Exec(context.Background(), query, value, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchAlbums(userId int, query string) ([]Album, float64, error) {
|
||||||
|
likePattern := "%" + query + "%"
|
||||||
|
rows, err := Pool.Query(context.Background(),
|
||||||
|
`SELECT id, user_id, title, artist_id, cover_url, spotify_id, musicbrainz_id, similarity(title, $2) as sim
|
||||||
|
FROM albums WHERE user_id = $1 AND (similarity(title, $2) > 0.1 OR LOWER(title) LIKE LOWER($3))
|
||||||
|
ORDER BY similarity(title, $2) DESC LIMIT 20`,
|
||||||
|
userId, query, likePattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var albums []Album
|
||||||
|
var maxSim float64
|
||||||
|
for rows.Next() {
|
||||||
|
var a Album
|
||||||
|
var artistIdVal int
|
||||||
|
var coverUrlPg, spotifyIdPg, musicbrainzIdPg pgtype.Text
|
||||||
|
var sim float64
|
||||||
|
err := rows.Scan(&a.Id, &a.UserId, &a.Title, &artistIdVal, &coverUrlPg, &spotifyIdPg, &musicbrainzIdPg, &sim)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
a.ArtistId = artistIdVal
|
||||||
|
if coverUrlPg.Status == pgtype.Present {
|
||||||
|
a.CoverUrl = coverUrlPg.String
|
||||||
|
}
|
||||||
|
if spotifyIdPg.Status == pgtype.Present {
|
||||||
|
a.SpotifyId = spotifyIdPg.String
|
||||||
|
}
|
||||||
|
if musicbrainzIdPg.Status == pgtype.Present {
|
||||||
|
a.MusicbrainzId = musicbrainzIdPg.String
|
||||||
|
}
|
||||||
|
albums = append(albums, a)
|
||||||
|
if sim > maxSim {
|
||||||
|
maxSim = sim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return albums, maxSim, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOrCreateSong(userId int, title string, artistId int, albumId int) (int, bool, error) {
|
||||||
|
if title == "" {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int
|
||||||
|
err := Pool.QueryRow(context.Background(),
|
||||||
|
`SELECT id FROM songs
|
||||||
|
WHERE user_id = $1 AND title = $2 AND artist_id = $3
|
||||||
|
AND (album_id = $4 OR (album_id IS NULL AND $4 IS NULL))`,
|
||||||
|
userId, title, artistId, albumId).Scan(&id)
|
||||||
|
if err == nil {
|
||||||
|
return id, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumIdVal pgtype.Int4
|
||||||
|
if albumId > 0 {
|
||||||
|
albumIdVal = pgtype.Int4{Int: int32(albumId), Status: pgtype.Present}
|
||||||
|
} else {
|
||||||
|
albumIdVal.Status = pgtype.Null
|
||||||
|
}
|
||||||
|
|
||||||
|
err = Pool.QueryRow(context.Background(),
|
||||||
|
`INSERT INTO songs (user_id, title, artist_id, album_id) VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (user_id, title, artist_id, album_id) DO UPDATE SET album_id = EXCLUDED.album_id
|
||||||
|
RETURNING id`,
|
||||||
|
userId, title, artistId, albumIdVal).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating song: %v\n", err)
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
return id, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSongById(id int) (Song, error) {
|
||||||
|
var song Song
|
||||||
|
err := Pool.QueryRow(context.Background(),
|
||||||
|
"SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id FROM songs WHERE id = $1",
|
||||||
|
id).Scan(&song.Id, &song.UserId, &song.Title, &song.ArtistId, &song.AlbumId,
|
||||||
|
&song.DurationMs, &song.SpotifyId, &song.MusicbrainzId)
|
||||||
|
if err != nil {
|
||||||
|
return Song{}, err
|
||||||
|
}
|
||||||
|
return song, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSongByName(userId int, title string, artistId int) (Song, error) {
|
||||||
|
var song Song
|
||||||
|
var artistIdVal, albumIdVal pgtype.Int4
|
||||||
|
var durationMs *int
|
||||||
|
var spotifyIdPg, musicbrainzIdPg pgtype.Text
|
||||||
|
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
if artistId > 0 {
|
||||||
|
query = `SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id
|
||||||
|
FROM songs WHERE user_id = $1 AND title = $2 AND artist_id = $3`
|
||||||
|
args = []interface{}{userId, title, artistId}
|
||||||
|
} else {
|
||||||
|
query = `SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id
|
||||||
|
FROM songs WHERE user_id = $1 AND title = $2`
|
||||||
|
args = []interface{}{userId, title}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Pool.QueryRow(context.Background(), query, args...).Scan(
|
||||||
|
&song.Id, &song.UserId, &song.Title, &artistIdVal, &albumIdVal,
|
||||||
|
&durationMs, &spotifyIdPg, &musicbrainzIdPg)
|
||||||
|
if err != nil {
|
||||||
|
return Song{}, err
|
||||||
|
}
|
||||||
|
if artistIdVal.Status == pgtype.Present {
|
||||||
|
song.ArtistId = int(artistIdVal.Int)
|
||||||
|
}
|
||||||
|
if albumIdVal.Status == pgtype.Present {
|
||||||
|
song.AlbumId = int(albumIdVal.Int)
|
||||||
|
}
|
||||||
|
if durationMs != nil {
|
||||||
|
song.DurationMs = *durationMs
|
||||||
|
}
|
||||||
|
if spotifyIdPg.Status == pgtype.Present {
|
||||||
|
song.SpotifyId = spotifyIdPg.String
|
||||||
|
}
|
||||||
|
if musicbrainzIdPg.Status == pgtype.Present {
|
||||||
|
song.MusicbrainzId = musicbrainzIdPg.String
|
||||||
|
}
|
||||||
|
return song, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateSong(id int, title string, durationMs int, spotifyId, musicbrainzId string) error {
|
||||||
|
_, err := Pool.Exec(context.Background(),
|
||||||
|
`UPDATE songs SET title = $1, duration_ms = $2, spotify_id = $3, musicbrainz_id = $4 WHERE id = $5`,
|
||||||
|
title, durationMs, spotifyId, musicbrainzId, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchSongs(userId int, query string) ([]Song, float64, error) {
|
||||||
|
likePattern := "%" + query + "%"
|
||||||
|
rows, err := Pool.Query(context.Background(),
|
||||||
|
`SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id, similarity(title, $2) as sim
|
||||||
|
FROM songs WHERE user_id = $1 AND (similarity(title, $2) > 0.1 OR LOWER(title) LIKE LOWER($3))
|
||||||
|
ORDER BY similarity(title, $2) DESC LIMIT 20`,
|
||||||
|
userId, query, likePattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var songs []Song
|
||||||
|
var maxSim float64
|
||||||
|
for rows.Next() {
|
||||||
|
var s Song
|
||||||
|
var artistIdVal, albumIdVal int
|
||||||
|
var durationMsVal *int
|
||||||
|
var spotifyIdPg, musicbrainzIdPg pgtype.Text
|
||||||
|
var sim float64
|
||||||
|
err := rows.Scan(&s.Id, &s.UserId, &s.Title, &artistIdVal, &albumIdVal, &durationMsVal, &spotifyIdPg, &musicbrainzIdPg, &sim)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
s.ArtistId = artistIdVal
|
||||||
|
s.AlbumId = albumIdVal
|
||||||
|
if durationMsVal != nil {
|
||||||
|
s.DurationMs = *durationMsVal
|
||||||
|
}
|
||||||
|
if spotifyIdPg.Status == pgtype.Present {
|
||||||
|
s.SpotifyId = spotifyIdPg.String
|
||||||
|
}
|
||||||
|
if musicbrainzIdPg.Status == pgtype.Present {
|
||||||
|
s.MusicbrainzId = musicbrainzIdPg.String
|
||||||
|
}
|
||||||
|
songs = append(songs, s)
|
||||||
|
if sim > maxSim {
|
||||||
|
maxSim = sim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return songs, maxSim, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetArtistStats(userId, artistId int) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := Pool.QueryRow(context.Background(),
|
||||||
|
"SELECT COUNT(*) FROM history WHERE user_id = $1 AND artist_id = $2",
|
||||||
|
userId, artistId).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSongStats(userId, songId int) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := Pool.QueryRow(context.Background(),
|
||||||
|
"SELECT COUNT(*) FROM history WHERE user_id = $1 AND song_id = $2",
|
||||||
|
userId, songId).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func MergeArtists(userId int, fromArtistId, toArtistId int) error {
|
||||||
|
_, err := Pool.Exec(context.Background(),
|
||||||
|
`UPDATE history SET artist_id = $1 WHERE user_id = $2 AND artist_id = $3`,
|
||||||
|
toArtistId, userId, fromArtistId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = Pool.Exec(context.Background(),
|
||||||
|
`UPDATE songs SET artist_id = $1 WHERE user_id = $2 AND artist_id = $3`,
|
||||||
|
toArtistId, userId, fromArtistId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = Pool.Exec(context.Background(),
|
||||||
|
`DELETE FROM artists WHERE id = $1 AND user_id = $2`,
|
||||||
|
fromArtistId, userId)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetHistoryForArtist(userId, artistId int, limit, offset int) ([]ScrobbleEntry, error) {
|
||||||
|
rows, err := Pool.Query(context.Background(),
|
||||||
|
`SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform,
|
||||||
|
(SELECT name FROM artists WHERE id = h.artist_id) as artist_name
|
||||||
|
FROM history h WHERE h.user_id = $1 AND h.artist_id = $2
|
||||||
|
ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`,
|
||||||
|
userId, artistId, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []ScrobbleEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e ScrobbleEntry
|
||||||
|
err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetHistoryForSong(userId, songId int, limit, offset int) ([]ScrobbleEntry, error) {
|
||||||
|
rows, err := Pool.Query(context.Background(),
|
||||||
|
`SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform,
|
||||||
|
(SELECT name FROM artists WHERE id = h.artist_id) as artist_name
|
||||||
|
FROM history h WHERE h.user_id = $1 AND h.song_id = $2
|
||||||
|
ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`,
|
||||||
|
userId, songId, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []ScrobbleEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e ScrobbleEntry
|
||||||
|
err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScrobbleEntry struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
SongName string
|
||||||
|
ArtistName string
|
||||||
|
AlbumName string
|
||||||
|
MsPlayed int
|
||||||
|
Platform string
|
||||||
|
}
|
||||||
|
|
||||||
|
func MigrateHistoryEntities() error {
|
||||||
|
rows, err := Pool.Query(context.Background(),
|
||||||
|
`SELECT DISTINCT h.user_id, h.artist, h.song_name, h.album_name
|
||||||
|
FROM history h
|
||||||
|
WHERE h.artist_id IS NULL OR h.song_id IS NULL`)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error fetching history for migration: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type UniqueEntity struct {
|
||||||
|
UserId int
|
||||||
|
Artist string
|
||||||
|
SongName string
|
||||||
|
Album string
|
||||||
|
}
|
||||||
|
|
||||||
|
var entities []UniqueEntity
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for rows.Next() {
|
||||||
|
var r UniqueEntity
|
||||||
|
if err := rows.Scan(&r.UserId, &r.Artist, &r.SongName, &r.Album); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%d-%s-%s-%s", r.UserId, r.Artist, r.SongName, r.Album)
|
||||||
|
if !seen[key] {
|
||||||
|
seen[key] = true
|
||||||
|
entities = append(entities, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
artistIds := make(map[string]int)
|
||||||
|
albumIds := make(map[string]int)
|
||||||
|
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.Artist == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%d-%s", e.UserId, e.Artist)
|
||||||
|
if _, exists := artistIds[key]; !exists {
|
||||||
|
id, _, err := GetOrCreateArtist(e.UserId, e.Artist)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
artistIds[key] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.Album == "" || e.Artist == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
artistKey := fmt.Sprintf("%d-%s", e.UserId, e.Artist)
|
||||||
|
artistId, ok := artistIds[artistKey]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
albumKey := fmt.Sprintf("%d-%s-%d", e.UserId, e.Album, artistId)
|
||||||
|
if _, exists := albumIds[albumKey]; !exists {
|
||||||
|
id, _, err := GetOrCreateAlbum(e.UserId, e.Album, artistId)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
albumIds[albumKey] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entities {
|
||||||
|
if e.SongName == "" || e.Artist == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
artistKey := fmt.Sprintf("%d-%s", e.UserId, e.Artist)
|
||||||
|
artistId, ok := artistIds[artistKey]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumId int
|
||||||
|
if e.Album != "" {
|
||||||
|
albumKey := fmt.Sprintf("%d-%s-%d", e.UserId, e.Album, artistId)
|
||||||
|
albumId = albumIds[albumKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
songId, _, err := GetOrCreateSong(e.UserId, e.SongName, artistId, albumId)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Pool.Exec(context.Background(),
|
||||||
|
`UPDATE history SET artist_id = $1, song_id = $2
|
||||||
|
WHERE user_id = $3 AND artist = $4 AND song_name = $5
|
||||||
|
AND (artist_id IS NULL OR song_id IS NULL)`,
|
||||||
|
artistId, songId, e.UserId, e.Artist, e.SongName)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error updating history entity IDs: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAlbumStats(userId, albumId int) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := Pool.QueryRow(context.Background(),
|
||||||
|
`SELECT COUNT(*) FROM history h
|
||||||
|
JOIN songs s ON h.song_id = s.id
|
||||||
|
WHERE h.user_id = $1 AND s.album_id = $2`,
|
||||||
|
userId, albumId).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetHistoryForAlbum(userId, albumId int, limit, offset int) ([]ScrobbleEntry, error) {
|
||||||
|
rows, err := Pool.Query(context.Background(),
|
||||||
|
`SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform,
|
||||||
|
(SELECT name FROM artists WHERE id = h.artist_id) as artist_name
|
||||||
|
FROM history h
|
||||||
|
JOIN songs s ON h.song_id = s.id
|
||||||
|
WHERE h.user_id = $1 AND s.album_id = $2
|
||||||
|
ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`,
|
||||||
|
userId, albumId, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []ScrobbleEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e ScrobbleEntry
|
||||||
|
err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
@@ -118,12 +118,33 @@ func SaveScrobble(scrobble Scrobble) error {
|
|||||||
return fmt.Errorf("duplicate scrobble")
|
return fmt.Errorf("duplicate scrobble")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
artistId, _, err := db.GetOrCreateArtist(scrobble.UserId, scrobble.Artist)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting/creating artist: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumId int
|
||||||
|
if scrobble.Album != "" {
|
||||||
|
albumId, _, err = db.GetOrCreateAlbum(scrobble.UserId, scrobble.Album, artistId)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting/creating album: %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
songId, _, err := db.GetOrCreateSong(scrobble.UserId, scrobble.SongName, artistId, albumId)
|
||||||
|
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)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
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, artistId, songId)
|
||||||
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
|
||||||
|
|||||||
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 |
170
static/menu.js
170
static/menu.js
@@ -23,10 +23,178 @@ 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 < 2) {
|
||||||
|
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) {
|
||||||
|
// Update bio display if it exists
|
||||||
|
var bioDisplay = document.getElementById('bio-display');
|
||||||
|
if (bioDisplay && data.bio !== undefined) {
|
||||||
|
bioDisplay.textContent = data.bio;
|
||||||
|
}
|
||||||
|
// Update info display if it exists
|
||||||
|
var infoDisplay = document.getElementById('info-display');
|
||||||
|
if (infoDisplay && data.title !== undefined) {
|
||||||
|
// Will be reloaded anyway, but close modal first
|
||||||
|
}
|
||||||
|
closeEditModal();
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|||||||
290
static/style.css
290
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 {
|
||||||
@@ -185,6 +274,14 @@
|
|||||||
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;
|
||||||
@@ -227,6 +324,14 @@
|
|||||||
tr:nth-child(even) {
|
tr:nth-child(even) {
|
||||||
background-color: #111;
|
background-color: #111;
|
||||||
}
|
}
|
||||||
|
a {
|
||||||
|
color: #AFA;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #FFF;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-section {
|
.import-section {
|
||||||
@@ -454,3 +559,188 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
68
templates/album.gohtml
Normal file
68
templates/album.gohtml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{{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 .Artist.Name}}
|
||||||
|
<h2><a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}">{{.Artist.Name}}</a></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><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></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}}
|
||||||
70
templates/artist.gohtml
Normal file
70
templates/artist.gohtml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{{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><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td>
|
||||||
|
<td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td>
|
||||||
|
<td><a href="/profile/{{$username}}/album/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td>
|
||||||
|
<td 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">
|
||||||
@@ -44,6 +50,9 @@
|
|||||||
<!-- 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}}
|
||||||
|
|
||||||
<script src="/files/menu.js"></script>
|
<script src="/files/menu.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -29,10 +29,11 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{$artists := .Artists}}
|
{{$artists := .Artists}}
|
||||||
{{$times := .Times}}
|
{{$times := .Times}}
|
||||||
|
{{$username := .Username}}
|
||||||
{{range $index, $title := .Titles}}
|
{{range $index, $title := .Titles}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{index $artists $index}}</td>
|
<td><a href="/profile/{{$username}}/artist/{{urlquery (index $artists $index)}}">{{index $artists $index}}</a></td>
|
||||||
<td>{{$title}}</td>
|
<td><a href="/profile/{{$username}}/song/{{urlquery (index $artists $index)}}/{{urlquery $title}}">{{$title}}</a></td>
|
||||||
<td title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</td>
|
<td title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
66
templates/song.gohtml
Normal file
66
templates/song.gohtml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{{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>
|
||||||
|
{{end}}
|
||||||
|
</h1>
|
||||||
|
{{if .Artist.Name}}
|
||||||
|
<h2><a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}">{{.Artist.Name}}</a></h2>
|
||||||
|
{{end}}
|
||||||
|
{{if .Album.Title}}
|
||||||
|
<h3><a href="/profile/{{.Username}}/album/{{urlquery .Artist.Name}}/{{urlquery .Album.Title}}">{{.Album.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">
|
||||||
|
<h3>Scrobbles</h3>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
{{$username := .Username}}
|
||||||
|
{{range .Times}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td>
|
||||||
|
<td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td>
|
||||||
|
<td><a href="/profile/{{$username}}/album/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td>
|
||||||
|
<td 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}}
|
||||||
|
{{end}}
|
||||||
898
web/entity.go
Normal file
898
web/entity.go
Normal file
@@ -0,0 +1,898 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"muzi/db"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArtistData struct {
|
||||||
|
Username string
|
||||||
|
Artist db.Artist
|
||||||
|
ListenCount int
|
||||||
|
Songs []string
|
||||||
|
Titles []string
|
||||||
|
Times []db.ScrobbleEntry
|
||||||
|
Page int
|
||||||
|
Title string
|
||||||
|
LoggedInUsername string
|
||||||
|
TemplateName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SongData struct {
|
||||||
|
Username string
|
||||||
|
Song db.Song
|
||||||
|
Artist db.Artist
|
||||||
|
Album db.Album
|
||||||
|
ListenCount int
|
||||||
|
Times []db.ScrobbleEntry
|
||||||
|
Page int
|
||||||
|
Title string
|
||||||
|
LoggedInUsername string
|
||||||
|
TemplateName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumData struct {
|
||||||
|
Username string
|
||||||
|
Album db.Album
|
||||||
|
Artist db.Artist
|
||||||
|
ListenCount int
|
||||||
|
Times []db.ScrobbleEntry
|
||||||
|
Page int
|
||||||
|
Title string
|
||||||
|
LoggedInUsername string
|
||||||
|
TemplateName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func artistPageHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := chi.URLParam(r, "username")
|
||||||
|
artistName, err := url.QueryUnescape(chi.URLParam(r, "artist"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid artist name", http.StatusBadRequest)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, err := db.GetArtistByName(userId, artistName)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Cannot find artist %s: %v\n", artistName, err)
|
||||||
|
http.Error(w, "Artist not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pageStr := r.URL.Query().Get("page")
|
||||||
|
var pageInt int
|
||||||
|
if pageStr == "" {
|
||||||
|
pageInt = 1
|
||||||
|
} else {
|
||||||
|
pageInt, err = strconv.Atoi(pageStr)
|
||||||
|
if err != nil {
|
||||||
|
pageInt = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lim := 15
|
||||||
|
off := (pageInt - 1) * lim
|
||||||
|
|
||||||
|
listenCount, err := db.GetArtistStats(userId, artist.Id)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Cannot get artist stats: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := db.GetHistoryForArtist(userId, artist.Id, lim, off)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Cannot get history for artist: %v\n", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
artistData := ArtistData{
|
||||||
|
Username: username,
|
||||||
|
Artist: artist,
|
||||||
|
ListenCount: listenCount,
|
||||||
|
Times: entries,
|
||||||
|
Page: pageInt,
|
||||||
|
Title: artistName + " - " + username,
|
||||||
|
LoggedInUsername: getLoggedInUsername(r),
|
||||||
|
TemplateName: "artist",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = templates.ExecuteTemplate(w, "base", artistData)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func songPageHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := chi.URLParam(r, "username")
|
||||||
|
artistName, err := url.QueryUnescape(chi.URLParam(r, "artist"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid artist name", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
songTitle, err := url.QueryUnescape(chi.URLParam(r, "song"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid song title", http.StatusBadRequest)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, err := db.GetArtistByName(userId, artistName)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Cannot find artist %s: %v\n", artistName, err)
|
||||||
|
http.Error(w, "Artist not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
song, err := db.GetSongByName(userId, songTitle, artist.Id)
|
||||||
|
if err != nil {
|
||||||
|
songs, _, searchErr := db.SearchSongs(userId, songTitle)
|
||||||
|
if searchErr == nil && len(songs) > 0 {
|
||||||
|
song = songs[0]
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Cannot find song %s: %v\n", songTitle, err)
|
||||||
|
http.Error(w, "Song not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, _ = db.GetArtistById(song.ArtistId)
|
||||||
|
var album db.Album
|
||||||
|
if song.AlbumId > 0 {
|
||||||
|
album, _ = db.GetAlbumById(song.AlbumId)
|
||||||
|
}
|
||||||
|
|
||||||
|
pageStr := r.URL.Query().Get("page")
|
||||||
|
var pageInt int
|
||||||
|
if pageStr == "" {
|
||||||
|
pageInt = 1
|
||||||
|
} else {
|
||||||
|
pageInt, err = strconv.Atoi(pageStr)
|
||||||
|
if err != nil {
|
||||||
|
pageInt = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lim := 15
|
||||||
|
off := (pageInt - 1) * lim
|
||||||
|
|
||||||
|
listenCount, err := db.GetSongStats(userId, song.Id)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Cannot get song stats: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := db.GetHistoryForSong(userId, song.Id, lim, off)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Cannot get history for song: %v\n", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
songData := SongData{
|
||||||
|
Username: username,
|
||||||
|
Song: song,
|
||||||
|
Artist: artist,
|
||||||
|
Album: album,
|
||||||
|
ListenCount: listenCount,
|
||||||
|
Times: entries,
|
||||||
|
Page: pageInt,
|
||||||
|
Title: songTitle + " - " + username,
|
||||||
|
LoggedInUsername: getLoggedInUsername(r),
|
||||||
|
TemplateName: "song",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = templates.ExecuteTemplate(w, "base", songData)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func editArtistHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := getLoggedInUsername(r)
|
||||||
|
if username == "" {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
artistIdStr := chi.URLParam(r, "id")
|
||||||
|
artistId, err := strconv.Atoi(artistIdStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid artist ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.ParseForm()
|
||||||
|
name := r.Form.Get("name")
|
||||||
|
imageUrl := r.Form.Get("image_url")
|
||||||
|
bio := r.Form.Get("bio")
|
||||||
|
spotifyId := r.Form.Get("spotify_id")
|
||||||
|
musicbrainzId := r.Form.Get("musicbrainz_id")
|
||||||
|
|
||||||
|
err = db.UpdateArtist(artistId, name, imageUrl, bio, spotifyId, musicbrainzId)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error updating artist: %v\n", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/artist/"+url.QueryEscape(name), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func editSongHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := getLoggedInUsername(r)
|
||||||
|
if username == "" {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.ParseForm()
|
||||||
|
title := r.Form.Get("title")
|
||||||
|
spotifyId := r.Form.Get("spotify_id")
|
||||||
|
musicbrainzId := r.Form.Get("musicbrainz_id")
|
||||||
|
|
||||||
|
songIdStr := chi.URLParam(r, "id")
|
||||||
|
songId, err := strconv.Atoi(songIdStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid song ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.UpdateSong(songId, title, 0, spotifyId, musicbrainzId)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error updating song: %v\n", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
song, err := db.GetSongById(songId)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting song after update: %v\n", err)
|
||||||
|
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, err := db.GetArtistById(song.ArtistId)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting artist: %v\n", err)
|
||||||
|
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/profile/"+username+"/song/"+url.QueryEscape(artist.Name)+"/"+url.QueryEscape(title), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func albumPageHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := chi.URLParam(r, "username")
|
||||||
|
artistName, err := url.QueryUnescape(chi.URLParam(r, "artist"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid artist name", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
albumTitle, err := url.QueryUnescape(chi.URLParam(r, "album"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid album title", http.StatusBadRequest)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, err := db.GetArtistByName(userId, artistName)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Cannot find artist %s: %v\n", artistName, err)
|
||||||
|
http.Error(w, "Artist not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
album, err := db.GetAlbumByName(userId, albumTitle, artist.Id)
|
||||||
|
if err != nil {
|
||||||
|
albums, _, searchErr := db.SearchAlbums(userId, albumTitle)
|
||||||
|
if searchErr == nil && len(albums) > 0 {
|
||||||
|
album = albums[0]
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Cannot find album %s: %v\n", albumTitle, err)
|
||||||
|
http.Error(w, "Album not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, _ = db.GetArtistById(album.ArtistId)
|
||||||
|
|
||||||
|
pageStr := r.URL.Query().Get("page")
|
||||||
|
var pageInt int
|
||||||
|
if pageStr == "" {
|
||||||
|
pageInt = 1
|
||||||
|
} else {
|
||||||
|
pageInt, err = strconv.Atoi(pageStr)
|
||||||
|
if err != nil {
|
||||||
|
pageInt = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lim := 15
|
||||||
|
off := (pageInt - 1) * lim
|
||||||
|
|
||||||
|
listenCount, err := db.GetAlbumStats(userId, album.Id)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Cannot get album stats: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := db.GetHistoryForAlbum(userId, album.Id, lim, off)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Cannot get history for album: %v\n", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
albumData := AlbumData{
|
||||||
|
Username: username,
|
||||||
|
Album: album,
|
||||||
|
Artist: artist,
|
||||||
|
ListenCount: listenCount,
|
||||||
|
Times: entries,
|
||||||
|
Page: pageInt,
|
||||||
|
Title: albumTitle + " - " + username,
|
||||||
|
LoggedInUsername: getLoggedInUsername(r),
|
||||||
|
TemplateName: "album",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = templates.ExecuteTemplate(w, "base", albumData)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func editAlbumHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := getLoggedInUsername(r)
|
||||||
|
if username == "" {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.ParseForm()
|
||||||
|
title := r.Form.Get("title")
|
||||||
|
coverUrl := r.Form.Get("cover_url")
|
||||||
|
spotifyId := r.Form.Get("spotify_id")
|
||||||
|
musicbrainzId := r.Form.Get("musicbrainz_id")
|
||||||
|
|
||||||
|
albumIdStr := chi.URLParam(r, "id")
|
||||||
|
albumId, err := strconv.Atoi(albumIdStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid album ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.UpdateAlbum(albumId, title, coverUrl, spotifyId, musicbrainzId)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error updating album: %v\n", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
album, err := db.GetAlbumById(albumId)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting album after update: %v\n", err)
|
||||||
|
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, err := db.GetArtistById(album.ArtistId)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting artist: %v\n", err)
|
||||||
|
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/profile/"+username+"/album/"+url.QueryEscape(artist.Name)+"/"+url.QueryEscape(title), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type InlineEditRequest struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchEditRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Bio string `json:"bio"`
|
||||||
|
ImageUrl string `json:"image_url"`
|
||||||
|
SpotifyId string `json:"spotify_id"`
|
||||||
|
MusicbrainzId string `json:"musicbrainz_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
CoverUrl string `json:"cover_url"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func artistInlineEditHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := getLoggedInUsername(r)
|
||||||
|
if username == "" {
|
||||||
|
http.Error(w, "Not logged in", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
artistIdStr := chi.URLParam(r, "id")
|
||||||
|
artistId, err := strconv.Atoi(artistIdStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid artist ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
field := r.URL.Query().Get("field")
|
||||||
|
if field == "" {
|
||||||
|
http.Error(w, "Field required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req InlineEditRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateErr error
|
||||||
|
switch field {
|
||||||
|
case "name":
|
||||||
|
artist, _ := db.GetArtistById(artistId)
|
||||||
|
updateErr = db.UpdateArtist(artistId, req.Value, artist.ImageUrl, artist.Bio, artist.SpotifyId, artist.MusicbrainzId)
|
||||||
|
case "bio":
|
||||||
|
artist, _ := db.GetArtistById(artistId)
|
||||||
|
updateErr = db.UpdateArtist(artistId, artist.Name, artist.ImageUrl, req.Value, artist.SpotifyId, artist.MusicbrainzId)
|
||||||
|
case "image_url":
|
||||||
|
artist, _ := db.GetArtistById(artistId)
|
||||||
|
updateErr = db.UpdateArtist(artistId, artist.Name, req.Value, artist.Bio, artist.SpotifyId, artist.MusicbrainzId)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Invalid field", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if updateErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error updating artist: %v\n", updateErr)
|
||||||
|
http.Error(w, updateErr.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"success": "true"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func artistBatchEditHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := getLoggedInUsername(r)
|
||||||
|
if username == "" {
|
||||||
|
http.Error(w, "Not logged in", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
artistIdStr := chi.URLParam(r, "id")
|
||||||
|
artistId, err := strconv.Atoi(artistIdStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid artist ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req BatchEditRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, _ := db.GetArtistById(artistId)
|
||||||
|
|
||||||
|
name := artist.Name
|
||||||
|
bio := artist.Bio
|
||||||
|
imageUrl := artist.ImageUrl
|
||||||
|
spotifyId := artist.SpotifyId
|
||||||
|
musicbrainzId := artist.MusicbrainzId
|
||||||
|
|
||||||
|
if req.Name != "" {
|
||||||
|
name = req.Name
|
||||||
|
}
|
||||||
|
if req.Bio != "" || req.Bio == "" {
|
||||||
|
bio = req.Bio
|
||||||
|
}
|
||||||
|
if req.ImageUrl != "" {
|
||||||
|
imageUrl = req.ImageUrl
|
||||||
|
}
|
||||||
|
if req.SpotifyId != "" {
|
||||||
|
spotifyId = req.SpotifyId
|
||||||
|
}
|
||||||
|
if req.MusicbrainzId != "" {
|
||||||
|
musicbrainzId = req.MusicbrainzId
|
||||||
|
}
|
||||||
|
|
||||||
|
updateErr := db.UpdateArtist(artistId, name, imageUrl, bio, spotifyId, musicbrainzId)
|
||||||
|
if updateErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error updating artist: %v\n", updateErr)
|
||||||
|
http.Error(w, updateErr.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"success": "true"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func songInlineEditHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := getLoggedInUsername(r)
|
||||||
|
if username == "" {
|
||||||
|
http.Error(w, "Not logged in", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
songIdStr := chi.URLParam(r, "id")
|
||||||
|
songId, err := strconv.Atoi(songIdStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid song ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
field := r.URL.Query().Get("field")
|
||||||
|
if field == "" {
|
||||||
|
http.Error(w, "Field required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req InlineEditRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
song, _ := db.GetSongById(songId)
|
||||||
|
updateErr := db.UpdateSong(songId, req.Value, song.AlbumId, song.SpotifyId, song.MusicbrainzId)
|
||||||
|
|
||||||
|
if updateErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error updating song: %v\n", updateErr)
|
||||||
|
http.Error(w, updateErr.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"success": "true"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func songBatchEditHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := getLoggedInUsername(r)
|
||||||
|
if username == "" {
|
||||||
|
http.Error(w, "Not logged in", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
songIdStr := chi.URLParam(r, "id")
|
||||||
|
songId, err := strconv.Atoi(songIdStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid song ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req BatchEditRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
song, _ := db.GetSongById(songId)
|
||||||
|
|
||||||
|
title := song.Title
|
||||||
|
albumId := song.AlbumId
|
||||||
|
spotifyId := song.SpotifyId
|
||||||
|
musicbrainzId := song.MusicbrainzId
|
||||||
|
|
||||||
|
if req.Title != "" {
|
||||||
|
title = req.Title
|
||||||
|
}
|
||||||
|
if req.Duration > 0 {
|
||||||
|
albumId = req.Duration
|
||||||
|
}
|
||||||
|
if req.SpotifyId != "" {
|
||||||
|
spotifyId = req.SpotifyId
|
||||||
|
}
|
||||||
|
if req.MusicbrainzId != "" {
|
||||||
|
musicbrainzId = req.MusicbrainzId
|
||||||
|
}
|
||||||
|
|
||||||
|
updateErr := db.UpdateSong(songId, title, albumId, spotifyId, musicbrainzId)
|
||||||
|
if updateErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error updating song: %v\n", updateErr)
|
||||||
|
http.Error(w, updateErr.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"success": "true"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func albumInlineEditHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := getLoggedInUsername(r)
|
||||||
|
if username == "" {
|
||||||
|
http.Error(w, "Not logged in", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
albumIdStr := chi.URLParam(r, "id")
|
||||||
|
albumId, err := strconv.Atoi(albumIdStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid album ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
field := r.URL.Query().Get("field")
|
||||||
|
if field == "" {
|
||||||
|
http.Error(w, "Field required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req InlineEditRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateErr := db.UpdateAlbumField(albumId, field, req.Value)
|
||||||
|
|
||||||
|
if updateErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error updating album: %v\n", updateErr)
|
||||||
|
http.Error(w, updateErr.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"success": "true"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func albumBatchEditHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := getLoggedInUsername(r)
|
||||||
|
if username == "" {
|
||||||
|
http.Error(w, "Not logged in", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
albumIdStr := chi.URLParam(r, "id")
|
||||||
|
albumId, err := strconv.Atoi(albumIdStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid album ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req BatchEditRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
album, _ := db.GetAlbumById(albumId)
|
||||||
|
|
||||||
|
title := album.Title
|
||||||
|
coverUrl := album.CoverUrl
|
||||||
|
spotifyId := album.SpotifyId
|
||||||
|
musicbrainzId := album.MusicbrainzId
|
||||||
|
|
||||||
|
if req.Title != "" {
|
||||||
|
title = req.Title
|
||||||
|
}
|
||||||
|
if req.CoverUrl != "" {
|
||||||
|
coverUrl = req.CoverUrl
|
||||||
|
}
|
||||||
|
if req.SpotifyId != "" {
|
||||||
|
spotifyId = req.SpotifyId
|
||||||
|
}
|
||||||
|
if req.MusicbrainzId != "" {
|
||||||
|
musicbrainzId = req.MusicbrainzId
|
||||||
|
}
|
||||||
|
|
||||||
|
updateErr := db.UpdateAlbum(albumId, title, coverUrl, spotifyId, musicbrainzId)
|
||||||
|
if updateErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error updating album: %v\n", updateErr)
|
||||||
|
http.Error(w, updateErr.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"success": "true"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResult struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Score float64 `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := getLoggedInUsername(r)
|
||||||
|
if username == "" {
|
||||||
|
http.Error(w, "Not logged in", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, err := getUserIdByUsername(r.Context(), username)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "User not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
if len(query) < 2 {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte("[]"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []SearchResult
|
||||||
|
|
||||||
|
artists, artistSim, err := db.SearchArtists(userId, query)
|
||||||
|
if err == nil {
|
||||||
|
for _, a := range artists {
|
||||||
|
count, _ := db.GetArtistStats(userId, a.Id)
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
Type: "artist",
|
||||||
|
Name: a.Name,
|
||||||
|
Url: "/profile/" + username + "/artist/" + url.QueryEscape(a.Name),
|
||||||
|
Count: count,
|
||||||
|
Score: artistSim,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
songs, songSim, err := db.SearchSongs(userId, query)
|
||||||
|
if err == nil {
|
||||||
|
for _, s := range songs {
|
||||||
|
count, _ := db.GetSongStats(userId, s.Id)
|
||||||
|
artist, _ := db.GetArtistById(s.ArtistId)
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
Type: "song",
|
||||||
|
Name: s.Title,
|
||||||
|
Artist: artist.Name,
|
||||||
|
Url: "/profile/" + username + "/song/" + url.QueryEscape(artist.Name) + "/" + url.QueryEscape(s.Title),
|
||||||
|
Count: count,
|
||||||
|
Score: songSim,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
albums, albumSim, err := db.SearchAlbums(userId, query)
|
||||||
|
if err == nil {
|
||||||
|
for _, al := range albums {
|
||||||
|
count, _ := db.GetAlbumStats(userId, al.Id)
|
||||||
|
artist, _ := db.GetArtistById(al.ArtistId)
|
||||||
|
results = append(results, SearchResult{
|
||||||
|
Type: "album",
|
||||||
|
Name: al.Title,
|
||||||
|
Artist: artist.Name,
|
||||||
|
Url: "/profile/" + username + "/album/" + url.QueryEscape(artist.Name) + "/" + url.QueryEscape(al.Title),
|
||||||
|
Count: count,
|
||||||
|
Score: albumSim,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return results[i].Score+float64(results[i].Count)*0.01 > results[j].Score+float64(results[j].Count)*0.01
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
jsonBytes, _ := json.Marshal(results)
|
||||||
|
w.Write(jsonBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageUploadHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := getLoggedInUsername(r)
|
||||||
|
if username == "" {
|
||||||
|
http.Error(w, "Not logged in", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxFileSize = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
err := r.ParseMultipartForm(maxFileSize)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "File too large or invalid", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "No file uploaded", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if header.Size > maxFileSize {
|
||||||
|
http.Error(w, "File exceeds 5MB limit", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(header.Filename)
|
||||||
|
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".gif" && ext != ".webp" {
|
||||||
|
http.Error(w, "Invalid file type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := sha256.New()
|
||||||
|
io.Copy(hash, file)
|
||||||
|
file.Seek(0, 0)
|
||||||
|
|
||||||
|
hashBytes := hash.Sum(nil)
|
||||||
|
filename := hex.EncodeToString(hashBytes) + ext
|
||||||
|
|
||||||
|
uploadDir := "./static/uploads"
|
||||||
|
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating upload dir: %v\n", err)
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := os.Create(filepath.Join(uploadDir, filename))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating file: %v\n", err)
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(dst, file)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err)
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"url": "/files/uploads/" + filename,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
33
web/web.go
33
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"
|
||||||
@@ -35,6 +36,7 @@ func init() {
|
|||||||
"formatInt": formatInt,
|
"formatInt": formatInt,
|
||||||
"formatTimestamp": formatTimestamp,
|
"formatTimestamp": formatTimestamp,
|
||||||
"formatTimestampFull": formatTimestampFull,
|
"formatTimestampFull": formatTimestampFull,
|
||||||
|
"urlquery": url.QueryEscape,
|
||||||
}
|
}
|
||||||
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
|
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
|
||||||
}
|
}
|
||||||
@@ -81,6 +83,37 @@ func Start() {
|
|||||||
r.Get("/login", loginPageHandler())
|
r.Get("/login", loginPageHandler())
|
||||||
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/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)
|
||||||
|
|||||||
Reference in New Issue
Block a user