From 19ab88268ef4d167c98b45b67fd2d5013beb6e47 Mon Sep 17 00:00:00 2001 From: riwiwa Date: Sat, 28 Feb 2026 23:31:26 -0800 Subject: [PATCH] fix name collisions and add better track/artist/album edit UX --- .gitignore | 1 + db/entities.go | 110 ++++-- static/assets/pfps/default_album.png | Bin 0 -> 7221 bytes static/menu.js | 111 +++++- static/style.css | 193 +++++++++++ templates/album.gohtml | 46 ++- templates/artist.gohtml | 49 ++- templates/profile.gohtml | 2 +- templates/song.gohtml | 43 ++- web/entity.go | 499 +++++++++++++++++++++++++-- web/web.go | 28 +- 11 files changed, 973 insertions(+), 109 deletions(-) create mode 100644 static/assets/pfps/default_album.png diff --git a/.gitignore b/.gitignore index 756b431..58cfeb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ muzi +static/uploads/ diff --git a/db/entities.go b/db/entities.go index f3dfd4e..8278cf4 100644 --- a/db/entities.go +++ b/db/entities.go @@ -120,25 +120,27 @@ func UpdateArtist(id int, name, imageUrl, bio, spotifyId, musicbrainzId string) return err } -func SearchArtists(userId int, query string) ([]Artist, error) { +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 + `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, err + 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 - err := rows.Scan(&a.Id, &a.UserId, &a.Name, &imageUrlPg, &bioPg, &spotifyIdPg, &musicbrainzIdPg) + var sim float64 + err := rows.Scan(&a.Id, &a.UserId, &a.Name, &imageUrlPg, &bioPg, &spotifyIdPg, &musicbrainzIdPg, &sim) if err != nil { - return nil, err + return nil, 0, err } if imageUrlPg.Status == pgtype.Present { a.ImageUrl = imageUrlPg.String @@ -153,8 +155,11 @@ func SearchArtists(userId int, query string) ([]Artist, error) { a.MusicbrainzId = musicbrainzIdPg.String } artists = append(artists, a) + if sim > maxSim { + maxSim = sim + } } - return artists, nil + return artists, maxSim, nil } func GetOrCreateAlbum(userId int, title string, artistId int) (int, bool, error) { @@ -232,31 +237,56 @@ func GetAlbumByName(userId int, title string, artistId int) (Album, error) { func UpdateAlbum(id int, title, coverUrl, spotifyId, musicbrainzId string) error { _, err := Pool.Exec(context.Background(), - `UPDATE albums SET title = $1, cover_url = $2, spotify_id = $3, musicbrainz_id = $4 WHERE id = $5`, + `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 SearchAlbums(userId int, query string) ([]Album, error) { +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 + `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, err + 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 - err := rows.Scan(&a.Id, &a.UserId, &a.Title, &artistIdVal, &coverUrlPg, &spotifyIdPg, &musicbrainzIdPg) + var sim float64 + err := rows.Scan(&a.Id, &a.UserId, &a.Title, &artistIdVal, &coverUrlPg, &spotifyIdPg, &musicbrainzIdPg, &sim) if err != nil { - return nil, err + return nil, 0, err } a.ArtistId = artistIdVal if coverUrlPg.Status == pgtype.Present { @@ -269,8 +299,11 @@ func SearchAlbums(userId int, query string) ([]Album, error) { a.MusicbrainzId = musicbrainzIdPg.String } albums = append(albums, a) + if sim > maxSim { + maxSim = sim + } } - return albums, nil + return albums, maxSim, nil } func GetOrCreateSong(userId int, title string, artistId int, albumId int) (int, bool, error) { @@ -280,17 +313,26 @@ func GetOrCreateSong(userId int, title string, artistId int, albumId int) (int, var id int err := Pool.QueryRow(context.Background(), - "SELECT id FROM songs 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) + `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) DO UPDATE SET title = EXCLUDED.title + ON CONFLICT (user_id, title, artist_id, album_id) DO UPDATE SET album_id = EXCLUDED.album_id RETURNING id`, - userId, title, artistId, albumId).Scan(&id) + userId, title, artistId, albumIdVal).Scan(&id) if err != nil { fmt.Fprintf(os.Stderr, "Error creating song: %v\n", err) return 0, false, err @@ -312,7 +354,7 @@ func GetSongById(id int) (Song, error) { func GetSongByName(userId int, title string, artistId int) (Song, error) { var song Song - var artistIdVal, albumIdVal int + var artistIdVal, albumIdVal pgtype.Int4 var durationMs *int var spotifyIdPg, musicbrainzIdPg pgtype.Text @@ -334,8 +376,12 @@ func GetSongByName(userId int, title string, artistId int) (Song, error) { if err != nil { return Song{}, err } - song.ArtistId = artistIdVal - song.AlbumId = albumIdVal + 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 } @@ -355,30 +401,35 @@ func UpdateSong(id int, title string, durationMs int, spotifyId, musicbrainzId s return err } -func SearchSongs(userId int, query string) ([]Song, error) { +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 + `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, err + return nil, 0, err } defer rows.Close() var songs []Song + var maxSim float64 for rows.Next() { var s Song - var artistIdVal, albumIdVal, durationMsVal int + var artistIdVal, albumIdVal int + var durationMsVal *int var spotifyIdPg, musicbrainzIdPg pgtype.Text - err := rows.Scan(&s.Id, &s.UserId, &s.Title, &artistIdVal, &albumIdVal, &durationMsVal, &spotifyIdPg, &musicbrainzIdPg) + var sim float64 + err := rows.Scan(&s.Id, &s.UserId, &s.Title, &artistIdVal, &albumIdVal, &durationMsVal, &spotifyIdPg, &musicbrainzIdPg, &sim) if err != nil { - return nil, err + return nil, 0, err } s.ArtistId = artistIdVal s.AlbumId = albumIdVal - s.DurationMs = durationMsVal + if durationMsVal != nil { + s.DurationMs = *durationMsVal + } if spotifyIdPg.Status == pgtype.Present { s.SpotifyId = spotifyIdPg.String } @@ -386,8 +437,11 @@ func SearchSongs(userId int, query string) ([]Song, error) { s.MusicbrainzId = musicbrainzIdPg.String } songs = append(songs, s) + if sim > maxSim { + maxSim = sim + } } - return songs, nil + return songs, maxSim, nil } func GetArtistStats(userId, artistId int) (int, error) { diff --git a/static/assets/pfps/default_album.png b/static/assets/pfps/default_album.png new file mode 100644 index 0000000000000000000000000000000000000000..d01016d4779135ab640f5225f5abf0f1c8134494 GIT binary patch literal 7221 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 t|K59_|F8?6 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'; +} diff --git a/static/style.css b/static/style.css index 8b4ff7e..c4557ff 100644 --- a/static/style.css +++ b/static/style.css @@ -274,6 +274,14 @@ font-size: 15px; margin: 0; } + h2 a { + color: #AFA; + text-decoration: none; + } + h2 a:hover { + color: #FFF; + text-decoration: underline; + } img { object-fit: cover; width: 250px; @@ -551,3 +559,188 @@ a.button { a.button:hover { 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; +} diff --git a/templates/album.gohtml b/templates/album.gohtml index 35da523..f95b8d9 100644 --- a/templates/album.gohtml +++ b/templates/album.gohtml @@ -1,12 +1,17 @@ {{define "album"}}
{{if .Album.CoverUrl}} - {{.Album.Title}}'s cover + {{.Album.Title}}'s cover {{else}} - {{.Album.Title}}'s cover + {{.Album.Title}}'s cover {{end}}
-

{{.Album.Title}}

+

+ {{.Album.Title}} + {{if eq .LoggedInUsername .Username}} + + {{end}} +

{{if .Artist.Name}}

{{.Artist.Name}}

{{end}} @@ -17,18 +22,6 @@

{{formatInt .ListenCount}}

Listens

- {{if eq .LoggedInUsername .Username}} -
-

Edit Album

-
- - - - - -
-
- {{end}}

Scrobbles

@@ -42,7 +35,7 @@ {{range .Times}} - + @@ -51,8 +44,25 @@
{{if gt .Page 1 }} - Prev Page + Prev Page {{end}} - Next Page + Next Page
+ + {{if eq .LoggedInUsername .Username}} + + {{end}} {{end}} diff --git a/templates/artist.gohtml b/templates/artist.gohtml index f6866af..cb27d7a 100644 --- a/templates/artist.gohtml +++ b/templates/artist.gohtml @@ -1,13 +1,17 @@ {{define "artist"}}
{{if .Artist.ImageUrl}} - {{.Artist.Name}}'s image + {{.Artist.Name}}'s image {{else}} - {{.Artist.Name}}'s image + {{.Artist.Name}}'s image {{end}}
-

{{.Artist.Name}}

-

{{.Artist.Bio}}

+

+ {{.Artist.Name}} + {{if eq .LoggedInUsername .Username}} + + {{end}} +

@@ -15,19 +19,6 @@

{{formatInt .ListenCount}}

Listens

- {{if eq .LoggedInUsername .Username}} -
-

Edit Artist

-
- - - - - - - -
- {{end}}

Scrobbles

{{.ArtistName}}{{.SongName}}{{.SongName}} {{.AlbumName}} {{formatTimestamp .Timestamp}}
@@ -41,7 +32,7 @@ {{range .Times}} - + @@ -54,4 +45,26 @@ {{end}} Next Page +
+

Bio

+

{{.Artist.Bio}}

+
+ + {{if eq .LoggedInUsername .Username}} + + {{end}} {{end}} diff --git a/templates/profile.gohtml b/templates/profile.gohtml index e29ba4c..d0d56f0 100644 --- a/templates/profile.gohtml +++ b/templates/profile.gohtml @@ -33,7 +33,7 @@ {{range $index, $title := .Titles}} - + {{end}} diff --git a/templates/song.gohtml b/templates/song.gohtml index 3bb29d8..8a61f22 100644 --- a/templates/song.gohtml +++ b/templates/song.gohtml @@ -1,12 +1,17 @@ {{define "song"}}
-

{{.Song.Title}}

+

+ {{.Song.Title}} + {{if eq .LoggedInUsername .Username}} + + {{end}} +

{{if .Artist.Name}}

{{.Artist.Name}}

{{end}} {{if .Album.Title}} -

{{.Album.Title}}

+

{{.Album.Title}}

{{end}}
@@ -15,17 +20,6 @@

{{formatInt .ListenCount}}

Listens

- {{if eq .LoggedInUsername .Username}} -
-

Edit Song

-
- - - - - -
- {{end}}

Scrobbles

{{.ArtistName}}{{.SongName}}{{.SongName}} {{.AlbumName}} {{formatTimestamp .Timestamp}}
{{index $artists $index}}{{$title}}{{$title}} {{formatTimestamp (index $times $index)}}
@@ -39,7 +33,7 @@ {{range .Times}} - + @@ -48,8 +42,25 @@
{{if gt .Page 1 }} - Prev Page + Prev Page {{end}} - Next Page + Next Page
+ + {{if eq .LoggedInUsername .Username}} + + {{end}} {{end}} diff --git a/web/entity.go b/web/entity.go index 600c475..7c23f45 100644 --- a/web/entity.go +++ b/web/entity.go @@ -1,11 +1,16 @@ package web import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "io" "net/http" "net/url" "os" + "path/filepath" + "sort" "strconv" "muzi/db" @@ -121,6 +126,11 @@ func artistPageHandler() http.HandlerFunc { 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) @@ -134,9 +144,16 @@ func songPageHandler() http.HandlerFunc { return } - song, err := db.GetSongByName(userId, songTitle, 0) + artist, err := db.GetArtistByName(userId, artistName) if err != nil { - songs, searchErr := db.SearchSongs(userId, songTitle) + 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 { @@ -146,7 +163,7 @@ func songPageHandler() http.HandlerFunc { } } - artist, _ := db.GetArtistById(song.ArtistId) + artist, _ = db.GetArtistById(song.ArtistId) var album db.Album if song.AlbumId > 0 { album, _ = db.GetAlbumById(song.AlbumId) @@ -258,13 +275,32 @@ func editSongHandler() http.HandlerFunc { return } - http.Redirect(w, r, "/song/"+url.QueryEscape(title)+"?username="+username, http.StatusSeeOther) + 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) @@ -278,9 +314,16 @@ func albumPageHandler() http.HandlerFunc { return } - album, err := db.GetAlbumByName(userId, albumTitle, 0) + artist, err := db.GetArtistByName(userId, artistName) if err != nil { - albums, searchErr := db.SearchAlbums(userId, albumTitle) + 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 { @@ -290,7 +333,7 @@ func albumPageHandler() http.HandlerFunc { } } - artist, _ := db.GetArtistById(album.ArtistId) + artist, _ = db.GetArtistById(album.ArtistId) pageStr := r.URL.Query().Get("page") var pageInt int @@ -365,15 +408,344 @@ func editAlbumHandler() http.HandlerFunc { return } - http.Redirect(w, r, "/profile/"+username+"/album/"+url.QueryEscape(title), http.StatusSeeOther) + 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"` - Url string `json:"url"` - Count int `json:"count"` + 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 { @@ -399,7 +771,7 @@ func searchHandler() http.HandlerFunc { var results []SearchResult - artists, err := db.SearchArtists(userId, query) + artists, artistSim, err := db.SearchArtists(userId, query) if err == nil { for _, a := range artists { count, _ := db.GetArtistStats(userId, a.Id) @@ -408,38 +780,119 @@ func searchHandler() http.HandlerFunc { Name: a.Name, Url: "/profile/" + username + "/artist/" + url.QueryEscape(a.Name), Count: count, + Score: artistSim, }) } } - songs, err := db.SearchSongs(userId, query) + 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, - Url: "/profile/" + username + "/song/" + url.QueryEscape(s.Title), - Count: count, + 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, err := db.SearchAlbums(userId, query) + 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, - Url: "/profile/" + username + "/album/" + url.QueryEscape(al.Title), - Count: count, + 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, + }) + } +} diff --git a/web/web.go b/web/web.go index c2d0d85..a73d601 100644 --- a/web/web.go +++ b/web/web.go @@ -84,11 +84,35 @@ func Start() { r.Get("/createaccount", createAccountPageHandler()) r.Get("/profile/{username}", profilePageHandler()) r.Get("/profile/{username}/artist/{artist}", artistPageHandler()) - r.Get("/profile/{username}/song/{song}", songPageHandler()) - r.Get("/profile/{username}/album/{album}", albumPageHandler()) + 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.Post("/loginsubmit", loginSubmit)
{{.ArtistName}}{{.SongName}}{{.SongName}} {{.AlbumName}} {{formatTimestamp .Timestamp}}