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}}
-

+

{{else}}
-

+

{{end}}
-
{{.Album.Title}}
+
+ {{.Album.Title}}
+ {{if eq .LoggedInUsername .Username}}
+
+ {{end}}
+
{{if .Artist.Name}}
{{end}}
@@ -17,18 +22,6 @@
{{formatInt .ListenCount}}
Listens
- {{if eq .LoggedInUsername .Username}}
-
-
Edit Album
-
-
- {{end}}
Scrobbles
@@ -42,7 +35,7 @@
{{range .Times}}
| {{.ArtistName}} |
- {{.SongName}} |
+ {{.SongName}} |
{{.AlbumName}} |
{{formatTimestamp .Timestamp}} |
@@ -51,8 +44,25 @@
+
+ {{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}}
-

+

{{else}}
-

+

{{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
@@ -41,7 +32,7 @@
{{range .Times}}
| {{.ArtistName}} |
- {{.SongName}} |
+ {{.SongName}} |
{{.AlbumName}} |
{{formatTimestamp .Timestamp}} |
@@ -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}}
| {{index $artists $index}} |
- {{$title}} |
+ {{$title}} |
{{formatTimestamp (index $times $index)}} |
{{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}}
{{end}}
{{if .Album.Title}}
-
+
{{end}}
@@ -15,17 +20,6 @@
{{formatInt .ListenCount}}
Listens
- {{if eq .LoggedInUsername .Username}}
-
-
Edit Song
-
-
- {{end}}
Scrobbles
@@ -39,7 +33,7 @@
{{range .Times}}
| {{.ArtistName}} |
- {{.SongName}} |
+ {{.SongName}} |
{{.AlbumName}} |
{{formatTimestamp .Timestamp}} |
@@ -48,8 +42,25 @@
+
+ {{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)