fix name collisions and add better track/artist/album edit UX

This commit is contained in:
2026-02-28 23:31:26 -08:00
parent 99185499b1
commit 19ab88268e
11 changed files with 973 additions and 109 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
muzi muzi
static/uploads/

View File

@@ -120,25 +120,27 @@ func UpdateArtist(id int, name, imageUrl, bio, spotifyId, musicbrainzId string)
return err return err
} }
func SearchArtists(userId int, query string) ([]Artist, error) { func SearchArtists(userId int, query string) ([]Artist, float64, error) {
likePattern := "%" + query + "%" likePattern := "%" + query + "%"
rows, err := Pool.Query(context.Background(), 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)) 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`, ORDER BY similarity(name, $2) DESC LIMIT 20`,
userId, query, likePattern) userId, query, likePattern)
if err != nil { if err != nil {
return nil, err return nil, 0, err
} }
defer rows.Close() defer rows.Close()
var artists []Artist var artists []Artist
var maxSim float64
for rows.Next() { for rows.Next() {
var a Artist var a Artist
var imageUrlPg, bioPg, spotifyIdPg, musicbrainzIdPg pgtype.Text 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 { if err != nil {
return nil, err return nil, 0, err
} }
if imageUrlPg.Status == pgtype.Present { if imageUrlPg.Status == pgtype.Present {
a.ImageUrl = imageUrlPg.String a.ImageUrl = imageUrlPg.String
@@ -153,8 +155,11 @@ func SearchArtists(userId int, query string) ([]Artist, error) {
a.MusicbrainzId = musicbrainzIdPg.String a.MusicbrainzId = musicbrainzIdPg.String
} }
artists = append(artists, a) 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) { 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 { func UpdateAlbum(id int, title, coverUrl, spotifyId, musicbrainzId string) error {
_, err := Pool.Exec(context.Background(), _, 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) title, coverUrl, spotifyId, musicbrainzId, id)
return err 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 + "%" likePattern := "%" + query + "%"
rows, err := Pool.Query(context.Background(), 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)) 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`, ORDER BY similarity(title, $2) DESC LIMIT 20`,
userId, query, likePattern) userId, query, likePattern)
if err != nil { if err != nil {
return nil, err return nil, 0, err
} }
defer rows.Close() defer rows.Close()
var albums []Album var albums []Album
var maxSim float64
for rows.Next() { for rows.Next() {
var a Album var a Album
var artistIdVal int var artistIdVal int
var coverUrlPg, spotifyIdPg, musicbrainzIdPg pgtype.Text 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 { if err != nil {
return nil, err return nil, 0, err
} }
a.ArtistId = artistIdVal a.ArtistId = artistIdVal
if coverUrlPg.Status == pgtype.Present { if coverUrlPg.Status == pgtype.Present {
@@ -269,8 +299,11 @@ func SearchAlbums(userId int, query string) ([]Album, error) {
a.MusicbrainzId = musicbrainzIdPg.String a.MusicbrainzId = musicbrainzIdPg.String
} }
albums = append(albums, a) 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) { 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 var id int
err := Pool.QueryRow(context.Background(), 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))", `SELECT id FROM songs
userId, title, artistId).Scan(&id) 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 { if err == nil {
return id, false, 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(), err = Pool.QueryRow(context.Background(),
`INSERT INTO songs (user_id, title, artist_id, album_id) VALUES ($1, $2, $3, $4) `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`, RETURNING id`,
userId, title, artistId, albumId).Scan(&id) userId, title, artistId, albumIdVal).Scan(&id)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error creating song: %v\n", err) fmt.Fprintf(os.Stderr, "Error creating song: %v\n", err)
return 0, false, 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) { func GetSongByName(userId int, title string, artistId int) (Song, error) {
var song Song var song Song
var artistIdVal, albumIdVal int var artistIdVal, albumIdVal pgtype.Int4
var durationMs *int var durationMs *int
var spotifyIdPg, musicbrainzIdPg pgtype.Text var spotifyIdPg, musicbrainzIdPg pgtype.Text
@@ -334,8 +376,12 @@ func GetSongByName(userId int, title string, artistId int) (Song, error) {
if err != nil { if err != nil {
return Song{}, err return Song{}, err
} }
song.ArtistId = artistIdVal if artistIdVal.Status == pgtype.Present {
song.AlbumId = albumIdVal song.ArtistId = int(artistIdVal.Int)
}
if albumIdVal.Status == pgtype.Present {
song.AlbumId = int(albumIdVal.Int)
}
if durationMs != nil { if durationMs != nil {
song.DurationMs = *durationMs song.DurationMs = *durationMs
} }
@@ -355,30 +401,35 @@ func UpdateSong(id int, title string, durationMs int, spotifyId, musicbrainzId s
return err return err
} }
func SearchSongs(userId int, query string) ([]Song, error) { func SearchSongs(userId int, query string) ([]Song, float64, error) {
likePattern := "%" + query + "%" likePattern := "%" + query + "%"
rows, err := Pool.Query(context.Background(), 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)) 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`, ORDER BY similarity(title, $2) DESC LIMIT 20`,
userId, query, likePattern) userId, query, likePattern)
if err != nil { if err != nil {
return nil, err return nil, 0, err
} }
defer rows.Close() defer rows.Close()
var songs []Song var songs []Song
var maxSim float64
for rows.Next() { for rows.Next() {
var s Song var s Song
var artistIdVal, albumIdVal, durationMsVal int var artistIdVal, albumIdVal int
var durationMsVal *int
var spotifyIdPg, musicbrainzIdPg pgtype.Text 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 { if err != nil {
return nil, err return nil, 0, err
} }
s.ArtistId = artistIdVal s.ArtistId = artistIdVal
s.AlbumId = albumIdVal s.AlbumId = albumIdVal
s.DurationMs = durationMsVal if durationMsVal != nil {
s.DurationMs = *durationMsVal
}
if spotifyIdPg.Status == pgtype.Present { if spotifyIdPg.Status == pgtype.Present {
s.SpotifyId = spotifyIdPg.String s.SpotifyId = spotifyIdPg.String
} }
@@ -386,8 +437,11 @@ func SearchSongs(userId int, query string) ([]Song, error) {
s.MusicbrainzId = musicbrainzIdPg.String s.MusicbrainzId = musicbrainzIdPg.String
} }
songs = append(songs, s) songs = append(songs, s)
if sim > maxSim {
maxSim = sim
} }
return songs, nil }
return songs, maxSim, nil
} }
func GetArtistStats(userId, artistId int) (int, error) { func GetArtistStats(userId, artistId int) (int, error) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -23,10 +23,10 @@ document.addEventListener('DOMContentLoaded', function() {
menuOverlay.addEventListener('click', closeMenu); menuOverlay.addEventListener('click', closeMenu);
} }
// Close menu on escape key
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeMenu(); closeMenu();
closeEditModal();
} }
}); });
@@ -78,18 +78,123 @@ document.addEventListener('DOMContentLoaded', function() {
}, 300); }, 300);
}); });
// Close search on escape
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
searchResults.classList.remove('active'); searchResults.classList.remove('active');
} }
}); });
// Close search when clicking outside
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) { if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
searchResults.classList.remove('active'); 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';
}

View File

@@ -274,6 +274,14 @@
font-size: 15px; font-size: 15px;
margin: 0; margin: 0;
} }
h2 a {
color: #AFA;
text-decoration: none;
}
h2 a:hover {
color: #FFF;
text-decoration: underline;
}
img { img {
object-fit: cover; object-fit: cover;
width: 250px; width: 250px;
@@ -551,3 +559,188 @@ a.button {
a.button:hover { a.button:hover {
background: #1ed760; background: #1ed760;
} }
.edit-toggle {
cursor: pointer;
font-size: 0.8em;
margin-left: 8px;
color: #888;
vertical-align: middle;
}
.edit-toggle:hover {
color: #AFA;
}
.inline-edit-form {
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: 8px;
}
.inline-edit-form input,
.inline-edit-form textarea {
padding: 5px 10px;
border: 1px solid #444;
border-radius: 4px;
background: #333;
color: #AFA;
font-size: inherit;
font-family: inherit;
}
.inline-edit-form textarea {
min-width: 200px;
min-height: 60px;
}
.inline-edit-form button {
padding: 5px 10px;
background: #1DB954;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.inline-edit-form button:hover {
background: #1ed760;
}
.editable-image {
transition: opacity 0.2s;
}
.editable-image:hover {
opacity: 0.7;
}
.album-cover {
border-radius: 0 !important;
}
.edit-btn {
margin-left: 15px;
padding: 5px 15px;
background: #444;
color: #AFA;
border: 1px solid #AFA;
border-radius: 4px;
cursor: pointer;
font-size: 0.8em;
}
.edit-btn:hover {
background: #555;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.modal-content {
background: #2a2a2a;
padding: 30px;
border-radius: 15px;
min-width: 400px;
max-width: 500px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.modal-content h2 {
margin-top: 0;
margin-bottom: 20px;
}
.modal-content form {
display: flex;
flex-direction: column;
gap: 15px;
}
.modal-content label {
display: flex;
flex-direction: column;
text-align: left;
gap: 5px;
}
.modal-content input,
.modal-content textarea {
padding: 10px;
border: 1px solid #444;
border-radius: 5px;
background: #333;
color: #AFA;
font-size: 1em;
font-family: inherit;
}
.modal-content textarea {
min-height: 100px;
resize: vertical;
}
.modal-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 10px;
}
.modal-buttons button {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
}
.modal-buttons button[type="submit"] {
background: #1DB954;
color: #fff;
}
.modal-buttons button[type="submit"]:hover {
background: #1ed760;
}
.modal-buttons .cancel-btn {
background: #444;
color: #AFA;
}
.modal-buttons .cancel-btn:hover {
background: #555;
}
.bio-box {
margin-top: 40px;
padding: 20px;
background: #2a2a2a;
border-radius: 10px;
width: 100%;
text-align: left;
}
.bio-box h3 {
margin-top: 0;
border-bottom: 1px solid #444;
padding-bottom: 10px;
}
.bio-box p {
margin: 0;
line-height: 1.6;
}

View File

@@ -1,12 +1,17 @@
{{define "album"}} {{define "album"}}
<div class="profile-top"> <div class="profile-top">
{{if .Album.CoverUrl}} {{if .Album.CoverUrl}}
<img src="{{.Album.CoverUrl}}" alt="{{.Album.Title}}'s cover"> <img class="editable-image album-cover" data-entity="album" data-id="{{.Album.Id}}" data-field="cover_url" src="{{.Album.CoverUrl}}" alt="{{.Album.Title}}'s cover">
{{else}} {{else}}
<img src="/files/assets/pfps/default.png" alt="{{.Album.Title}}'s cover"> <img class="editable-image album-cover" data-entity="album" data-id="{{.Album.Id}}" data-field="cover_url" src="/files/assets/pfps/default_album.png" alt="{{.Album.Title}}'s cover">
{{end}} {{end}}
<div class="username-bio"> <div class="username-bio">
<h1>{{.Album.Title}}</h1> <h1>
{{.Album.Title}}
{{if eq .LoggedInUsername .Username}}
<button class="edit-btn" onclick="openEditModal()">Edit</button>
{{end}}
</h1>
{{if .Artist.Name}} {{if .Artist.Name}}
<h2><a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}">{{.Artist.Name}}</a></h2> <h2><a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}">{{.Artist.Name}}</a></h2>
{{end}} {{end}}
@@ -17,18 +22,6 @@
<h3>{{formatInt .ListenCount}}</h3> <p>Listens<p> <h3>{{formatInt .ListenCount}}</h3> <p>Listens<p>
</div> </div>
</div> </div>
{{if eq .LoggedInUsername .Username}}
<div class="edit-section">
<h3>Edit Album</h3>
<form method="POST" action="/profile/{{.Username}}/album/{{.Album.Id}}/edit">
<label>Title: <input type="text" name="title" value="{{.Album.Title}}"></label>
<label>Cover URL: <input type="text" name="cover_url" value="{{.Album.CoverUrl}}"></label>
<label>Spotify ID: <input type="text" name="spotify_id" value="{{.Album.SpotifyId}}"></label>
<label>MusicBrainz ID: <input type="text" name="musicbrainz_id" value="{{.Album.MusicbrainzId}}"></label>
<button type="submit">Save</button>
</form>
</div>
{{end}}
<div class="history"> <div class="history">
<h3>Scrobbles</h3> <h3>Scrobbles</h3>
<table> <table>
@@ -42,7 +35,7 @@
{{range .Times}} {{range .Times}}
<tr> <tr>
<td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td> <td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td>
<td><a href="/profile/{{$username}}/song/{{urlquery .SongName}}">{{.SongName}}</a></td> <td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td>
<td>{{.AlbumName}}</td> <td>{{.AlbumName}}</td>
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td> <td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>
</tr> </tr>
@@ -51,8 +44,25 @@
</div> </div>
<div class="page_buttons"> <div class="page_buttons">
{{if gt .Page 1 }} {{if gt .Page 1 }}
<a href="/profile/{{.Username}}/album/{{urlquery .Album.Title}}?page={{sub .Page 1}}">Prev Page</a> <a href="/profile/{{.Username}}/album/{{urlquery .Artist.Name}}/{{urlquery .Album.Title}}?page={{sub .Page 1}}">Prev Page</a>
{{end}} {{end}}
<a href="/profile/{{.Username}}/album/{{urlquery .Album.Title}}?page={{add .Page 1}}">Next Page</a> <a href="/profile/{{.Username}}/album/{{urlquery .Artist.Name}}/{{urlquery .Album.Title}}?page={{add .Page 1}}">Next Page</a>
</div> </div>
{{if eq .LoggedInUsername .Username}}
<div id="editModal" class="modal-overlay" style="display:none;">
<div class="modal-content">
<h2>Edit Album</h2>
<form id="editForm" data-entity="album" data-id="{{.Album.Id}}">
<label>Title: <input type="text" name="title" value="{{.Album.Title}}"></label>
<label>Spotify ID: <input type="text" name="spotify_id" value="{{.Album.SpotifyId}}"></label>
<label>MusicBrainz ID: <input type="text" name="musicbrainz_id" value="{{.Album.MusicbrainzId}}"></label>
<div class="modal-buttons">
<button type="button" class="cancel-btn" onclick="closeEditModal()">Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</div>
</div>
{{end}}
{{end}} {{end}}

View File

@@ -1,13 +1,17 @@
{{define "artist"}} {{define "artist"}}
<div class="profile-top"> <div class="profile-top">
{{if .Artist.ImageUrl}} {{if .Artist.ImageUrl}}
<img src="{{.Artist.ImageUrl}}" alt="{{.Artist.Name}}'s image"> <img class="editable-image" data-entity="artist" data-id="{{.Artist.Id}}" data-field="image_url" src="{{.Artist.ImageUrl}}" alt="{{.Artist.Name}}'s image">
{{else}} {{else}}
<img src="/files/assets/pfps/default_artist.png" alt="{{.Artist.Name}}'s image"> <img class="editable-image" data-entity="artist" data-id="{{.Artist.Id}}" data-field="image_url" src="/files/assets/pfps/default_artist.png" alt="{{.Artist.Name}}'s image">
{{end}} {{end}}
<div class="username-bio"> <div class="username-bio">
<h1>{{.Artist.Name}}</h1> <h1>
<h2>{{.Artist.Bio}}</h2> {{.Artist.Name}}
{{if eq .LoggedInUsername .Username}}
<button class="edit-btn" onclick="openEditModal()">Edit</button>
{{end}}
</h1>
</div> </div>
<div class="profile-top-blank"> <div class="profile-top-blank">
</div> </div>
@@ -15,19 +19,6 @@
<h3>{{formatInt .ListenCount}}</h3> <p>Listens<p> <h3>{{formatInt .ListenCount}}</h3> <p>Listens<p>
</div> </div>
</div> </div>
{{if eq .LoggedInUsername .Username}}
<div class="edit-section">
<h3>Edit Artist</h3>
<form method="POST" action="/profile/{{.Username}}/artist/{{.Artist.Id}}/edit">
<label>Name: <input type="text" name="name" value="{{.Artist.Name}}"></label>
<label>Image URL: <input type="text" name="image_url" value="{{.Artist.ImageUrl}}"></label>
<label>Bio: <textarea name="bio">{{.Artist.Bio}}</textarea></label>
<label>Spotify ID: <input type="text" name="spotify_id" value="{{.Artist.SpotifyId}}"></label>
<label>MusicBrainz ID: <input type="text" name="musicbrainz_id" value="{{.Artist.MusicbrainzId}}"></label>
<button type="submit">Save</button>
</form>
</div>
{{end}}
<div class="history"> <div class="history">
<h3>Scrobbles</h3> <h3>Scrobbles</h3>
<table> <table>
@@ -41,7 +32,7 @@
{{range .Times}} {{range .Times}}
<tr> <tr>
<td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td> <td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td>
<td><a href="/profile/{{$username}}/song/{{urlquery .SongName}}">{{.SongName}}</a></td> <td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td>
<td><a href="/profile/{{$username}}/album/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td> <td><a href="/profile/{{$username}}/album/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td>
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td> <td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>
</tr> </tr>
@@ -54,4 +45,26 @@
{{end}} {{end}}
<a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}?page={{add .Page 1}}">Next Page</a> <a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}?page={{add .Page 1}}">Next Page</a>
</div> </div>
<div class="bio-box">
<h3>Bio</h3>
<p id="bio-display">{{.Artist.Bio}}</p>
</div>
{{if eq .LoggedInUsername .Username}}
<div id="editModal" class="modal-overlay" style="display:none;">
<div class="modal-content">
<h2>Edit Artist</h2>
<form id="editForm" data-entity="artist" data-id="{{.Artist.Id}}">
<label>Name: <input type="text" name="name" value="{{.Artist.Name}}"></label>
<label>Bio: <textarea name="bio">{{.Artist.Bio}}</textarea></label>
<label>Spotify ID: <input type="text" name="spotify_id" value="{{.Artist.SpotifyId}}"></label>
<label>MusicBrainz ID: <input type="text" name="musicbrainz_id" value="{{.Artist.MusicbrainzId}}"></label>
<div class="modal-buttons">
<button type="button" class="cancel-btn" onclick="closeEditModal()">Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</div>
</div>
{{end}}
{{end}} {{end}}

View File

@@ -33,7 +33,7 @@
{{range $index, $title := .Titles}} {{range $index, $title := .Titles}}
<tr> <tr>
<td><a href="/profile/{{$username}}/artist/{{urlquery (index $artists $index)}}">{{index $artists $index}}</a></td> <td><a href="/profile/{{$username}}/artist/{{urlquery (index $artists $index)}}">{{index $artists $index}}</a></td>
<td><a href="/profile/{{$username}}/song/{{urlquery $title}}">{{$title}}</a></td> <td><a href="/profile/{{$username}}/song/{{urlquery (index $artists $index)}}/{{urlquery $title}}">{{$title}}</a></td>
<td title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</td> <td title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</td>
</tr> </tr>
{{end}} {{end}}

View File

@@ -1,12 +1,17 @@
{{define "song"}} {{define "song"}}
<div class="profile-top"> <div class="profile-top">
<div class="username-bio"> <div class="username-bio">
<h1>{{.Song.Title}}</h1> <h1>
{{.Song.Title}}
{{if eq .LoggedInUsername .Username}}
<button class="edit-btn" onclick="openEditModal()">Edit</button>
{{end}}
</h1>
{{if .Artist.Name}} {{if .Artist.Name}}
<h2><a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}">{{.Artist.Name}}</a></h2> <h2><a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}">{{.Artist.Name}}</a></h2>
{{end}} {{end}}
{{if .Album.Title}} {{if .Album.Title}}
<h3><a href="/profile/{{.Username}}/album/{{urlquery .Album.Title}}">{{.Album.Title}}</a></h3> <h3><a href="/profile/{{.Username}}/album/{{urlquery .Artist.Name}}/{{urlquery .Album.Title}}">{{.Album.Title}}</a></h3>
{{end}} {{end}}
</div> </div>
<div class="profile-top-blank"> <div class="profile-top-blank">
@@ -15,17 +20,6 @@
<h3>{{formatInt .ListenCount}}</h3> <p>Listens<p> <h3>{{formatInt .ListenCount}}</h3> <p>Listens<p>
</div> </div>
</div> </div>
{{if eq .LoggedInUsername .Username}}
<div class="edit-section">
<h3>Edit Song</h3>
<form method="POST" action="/profile/{{.Username}}/song/{{.Song.Id}}/edit">
<label>Title: <input type="text" name="title" value="{{.Song.Title}}"></label>
<label>Spotify ID: <input type="text" name="spotify_id" value="{{.Song.SpotifyId}}"></label>
<label>MusicBrainz ID: <input type="text" name="musicbrainz_id" value="{{.Song.MusicbrainzId}}"></label>
<button type="submit">Save</button>
</form>
</div>
{{end}}
<div class="history"> <div class="history">
<h3>Scrobbles</h3> <h3>Scrobbles</h3>
<table> <table>
@@ -39,7 +33,7 @@
{{range .Times}} {{range .Times}}
<tr> <tr>
<td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td> <td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td>
<td><a href="/profile/{{$username}}/song/{{urlquery .SongName}}">{{.SongName}}</a></td> <td><a href="/profile/{{$username}}/song/{{urlquery .ArtistName}}/{{urlquery .SongName}}">{{.SongName}}</a></td>
<td><a href="/profile/{{$username}}/album/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td> <td><a href="/profile/{{$username}}/album/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td>
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td> <td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>
</tr> </tr>
@@ -48,8 +42,25 @@
</div> </div>
<div class="page_buttons"> <div class="page_buttons">
{{if gt .Page 1 }} {{if gt .Page 1 }}
<a href="/profile/{{.Username}}/song/{{urlquery .Song.Title}}?page={{sub .Page 1}}">Prev Page</a> <a href="/profile/{{.Username}}/song/{{urlquery .Artist.Name}}/{{urlquery .Song.Title}}?page={{sub .Page 1}}">Prev Page</a>
{{end}} {{end}}
<a href="/profile/{{.Username}}/song/{{urlquery .Song.Title}}?page={{add .Page 1}}">Next Page</a> <a href="/profile/{{.Username}}/song/{{urlquery .Artist.Name}}/{{urlquery .Song.Title}}?page={{add .Page 1}}">Next Page</a>
</div> </div>
{{if eq .LoggedInUsername .Username}}
<div id="editModal" class="modal-overlay" style="display:none;">
<div class="modal-content">
<h2>Edit Song</h2>
<form id="editForm" data-entity="song" data-id="{{.Song.Id}}">
<label>Title: <input type="text" name="title" value="{{.Song.Title}}"></label>
<label>Spotify ID: <input type="text" name="spotify_id" value="{{.Song.SpotifyId}}"></label>
<label>MusicBrainz ID: <input type="text" name="musicbrainz_id" value="{{.Song.MusicbrainzId}}"></label>
<div class="modal-buttons">
<button type="button" class="cancel-btn" onclick="closeEditModal()">Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</div>
</div>
{{end}}
{{end}} {{end}}

View File

@@ -1,11 +1,16 @@
package web package web
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath"
"sort"
"strconv" "strconv"
"muzi/db" "muzi/db"
@@ -121,6 +126,11 @@ func artistPageHandler() http.HandlerFunc {
func songPageHandler() http.HandlerFunc { func songPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username") 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")) songTitle, err := url.QueryUnescape(chi.URLParam(r, "song"))
if err != nil { if err != nil {
http.Error(w, "Invalid song title", http.StatusBadRequest) http.Error(w, "Invalid song title", http.StatusBadRequest)
@@ -134,9 +144,16 @@ func songPageHandler() http.HandlerFunc {
return return
} }
song, err := db.GetSongByName(userId, songTitle, 0) artist, err := db.GetArtistByName(userId, artistName)
if err != nil { 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 { if searchErr == nil && len(songs) > 0 {
song = songs[0] song = songs[0]
} else { } else {
@@ -146,7 +163,7 @@ func songPageHandler() http.HandlerFunc {
} }
} }
artist, _ := db.GetArtistById(song.ArtistId) artist, _ = db.GetArtistById(song.ArtistId)
var album db.Album var album db.Album
if song.AlbumId > 0 { if song.AlbumId > 0 {
album, _ = db.GetAlbumById(song.AlbumId) album, _ = db.GetAlbumById(song.AlbumId)
@@ -258,13 +275,32 @@ func editSongHandler() http.HandlerFunc {
return 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 { func albumPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username") 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")) albumTitle, err := url.QueryUnescape(chi.URLParam(r, "album"))
if err != nil { if err != nil {
http.Error(w, "Invalid album title", http.StatusBadRequest) http.Error(w, "Invalid album title", http.StatusBadRequest)
@@ -278,9 +314,16 @@ func albumPageHandler() http.HandlerFunc {
return return
} }
album, err := db.GetAlbumByName(userId, albumTitle, 0) artist, err := db.GetArtistByName(userId, artistName)
if err != nil { 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 { if searchErr == nil && len(albums) > 0 {
album = albums[0] album = albums[0]
} else { } 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") pageStr := r.URL.Query().Get("page")
var pageInt int var pageInt int
@@ -365,15 +408,344 @@ func editAlbumHandler() http.HandlerFunc {
return 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 SearchResult struct {
Type string `json:"type"` Type string `json:"type"`
Name string `json:"name"` Name string `json:"name"`
Artist string `json:"artist"`
Url string `json:"url"` Url string `json:"url"`
Count int `json:"count"` Count int `json:"count"`
Score float64 `json:"-"`
} }
func searchHandler() http.HandlerFunc { func searchHandler() http.HandlerFunc {
@@ -399,7 +771,7 @@ func searchHandler() http.HandlerFunc {
var results []SearchResult var results []SearchResult
artists, err := db.SearchArtists(userId, query) artists, artistSim, err := db.SearchArtists(userId, query)
if err == nil { if err == nil {
for _, a := range artists { for _, a := range artists {
count, _ := db.GetArtistStats(userId, a.Id) count, _ := db.GetArtistStats(userId, a.Id)
@@ -408,38 +780,119 @@ func searchHandler() http.HandlerFunc {
Name: a.Name, Name: a.Name,
Url: "/profile/" + username + "/artist/" + url.QueryEscape(a.Name), Url: "/profile/" + username + "/artist/" + url.QueryEscape(a.Name),
Count: count, Count: count,
Score: artistSim,
}) })
} }
} }
songs, err := db.SearchSongs(userId, query) songs, songSim, err := db.SearchSongs(userId, query)
if err == nil { if err == nil {
for _, s := range songs { for _, s := range songs {
count, _ := db.GetSongStats(userId, s.Id) count, _ := db.GetSongStats(userId, s.Id)
artist, _ := db.GetArtistById(s.ArtistId)
results = append(results, SearchResult{ results = append(results, SearchResult{
Type: "song", Type: "song",
Name: s.Title, Name: s.Title,
Url: "/profile/" + username + "/song/" + url.QueryEscape(s.Title), Artist: artist.Name,
Url: "/profile/" + username + "/song/" + url.QueryEscape(artist.Name) + "/" + url.QueryEscape(s.Title),
Count: count, Count: count,
Score: songSim,
}) })
} }
} }
albums, err := db.SearchAlbums(userId, query) albums, albumSim, err := db.SearchAlbums(userId, query)
if err == nil { if err == nil {
for _, al := range albums { for _, al := range albums {
count, _ := db.GetAlbumStats(userId, al.Id) count, _ := db.GetAlbumStats(userId, al.Id)
artist, _ := db.GetArtistById(al.ArtistId)
results = append(results, SearchResult{ results = append(results, SearchResult{
Type: "album", Type: "album",
Name: al.Title, Name: al.Title,
Url: "/profile/" + username + "/album/" + url.QueryEscape(al.Title), Artist: artist.Name,
Url: "/profile/" + username + "/album/" + url.QueryEscape(artist.Name) + "/" + url.QueryEscape(al.Title),
Count: count, 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") w.Header().Set("Content-Type", "application/json")
jsonBytes, _ := json.Marshal(results) jsonBytes, _ := json.Marshal(results)
w.Write(jsonBytes) 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,
})
}
}

View File

@@ -84,11 +84,35 @@ func Start() {
r.Get("/createaccount", createAccountPageHandler()) r.Get("/createaccount", createAccountPageHandler())
r.Get("/profile/{username}", profilePageHandler()) r.Get("/profile/{username}", profilePageHandler())
r.Get("/profile/{username}/artist/{artist}", artistPageHandler()) r.Get("/profile/{username}/artist/{artist}", artistPageHandler())
r.Get("/profile/{username}/song/{song}", songPageHandler()) r.Get("/profile/{username}/song/{artist}/{song}", songPageHandler())
r.Get("/profile/{username}/album/{album}", albumPageHandler()) 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}/artist/{id}/edit", editArtistHandler())
r.Post("/profile/{username}/song/{id}/edit", editSongHandler()) r.Post("/profile/{username}/song/{id}/edit", editSongHandler())
r.Post("/profile/{username}/album/{id}/edit", editAlbumHandler()) 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("/search", searchHandler())
r.Get("/import", importPageHandler()) r.Get("/import", importPageHandler())
r.Post("/loginsubmit", loginSubmit) r.Post("/loginsubmit", loginSubmit)