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 0000000..d01016d Binary files /dev/null and b/static/assets/pfps/default_album.png differ diff --git a/static/menu.js b/static/menu.js index 4f55800..d86c3a5 100644 --- a/static/menu.js +++ b/static/menu.js @@ -23,10 +23,10 @@ document.addEventListener('DOMContentLoaded', function() { menuOverlay.addEventListener('click', closeMenu); } - // Close menu on escape key document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { closeMenu(); + closeEditModal(); } }); @@ -78,18 +78,123 @@ document.addEventListener('DOMContentLoaded', function() { }, 300); }); - // Close search on escape document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { searchResults.classList.remove('active'); } }); - // Close search when clicking outside 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'; +} 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}}