Add search and pages for tracks, albums, and artists

This commit is contained in:
2026-02-28 21:18:41 -08:00
parent 09ac8b7fb0
commit 99185499b1
13 changed files with 1551 additions and 10 deletions

107
db/db.go
View File

@@ -14,6 +14,9 @@ import (
var Pool *pgxpool.Pool
func CreateAllTables() error {
if err := CreateExtensions(); err != nil {
return err
}
if err := CreateHistoryTable(); err != nil {
return err
}
@@ -23,7 +26,32 @@ func CreateAllTables() error {
if err := CreateSessionsTable(); err != nil {
return err
}
return CreateSpotifyLastTrackTable()
if err := CreateSpotifyLastTrackTable(); err != nil {
return err
}
if err := CreateArtistsTable(); err != nil {
return err
}
if err := CreateAlbumsTable(); err != nil {
return err
}
if err := CreateSongsTable(); err != nil {
return err
}
if err := AddHistoryEntityColumns(); err != nil {
return err
}
return nil
}
func CreateExtensions() error {
_, err := Pool.Exec(context.Background(),
"CREATE EXTENSION IF NOT EXISTS pg_trgm;")
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating pg_trgm extension: %v\n", err)
return err
}
return nil
}
func GetDbUrl(dbName bool) string {
@@ -153,3 +181,80 @@ func CreateSpotifyLastTrackTable() error {
}
return nil
}
func CreateArtistsTable() error {
_, err := Pool.Exec(context.Background(),
`CREATE TABLE IF NOT EXISTS artists (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(pk) ON DELETE CASCADE,
name TEXT NOT NULL,
image_url TEXT,
bio TEXT,
spotify_id TEXT,
musicbrainz_id TEXT,
UNIQUE (user_id, name)
);
CREATE INDEX IF NOT EXISTS idx_artists_user_name ON artists(user_id, name);
CREATE INDEX IF NOT EXISTS idx_artists_user_name_trgm ON artists USING gin(name gin_trgm_ops);`)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating artists table: %v\n", err)
return err
}
return nil
}
func CreateAlbumsTable() error {
_, err := Pool.Exec(context.Background(),
`CREATE TABLE IF NOT EXISTS albums (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(pk) ON DELETE CASCADE,
title TEXT NOT NULL,
artist_id INTEGER REFERENCES artists(id) ON DELETE SET NULL,
cover_url TEXT,
spotify_id TEXT,
musicbrainz_id TEXT,
UNIQUE (user_id, title, artist_id)
);
CREATE INDEX IF NOT EXISTS idx_albums_user_title ON albums(user_id, title);
CREATE INDEX IF NOT EXISTS idx_albums_user_title_trgm ON albums USING gin(title gin_trgm_ops);`)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating albums table: %v\n", err)
return err
}
return nil
}
func CreateSongsTable() error {
_, err := Pool.Exec(context.Background(),
`CREATE TABLE IF NOT EXISTS songs (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(pk) ON DELETE CASCADE,
title TEXT NOT NULL,
artist_id INTEGER REFERENCES artists(id) ON DELETE SET NULL,
album_id INTEGER REFERENCES albums(id) ON DELETE SET NULL,
duration_ms INTEGER,
spotify_id TEXT,
musicbrainz_id TEXT,
UNIQUE (user_id, title, artist_id)
);
CREATE INDEX IF NOT EXISTS idx_songs_user_title ON songs(user_id, title);
CREATE INDEX IF NOT EXISTS idx_songs_user_title_trgm ON songs USING gin(title gin_trgm_ops);`)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating songs table: %v\n", err)
return err
}
return nil
}
func AddHistoryEntityColumns() error {
_, err := Pool.Exec(context.Background(),
`ALTER TABLE history ADD COLUMN IF NOT EXISTS artist_id INTEGER REFERENCES artists(id) ON DELETE SET NULL;
ALTER TABLE history ADD COLUMN IF NOT EXISTS song_id INTEGER REFERENCES songs(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_history_artist_id ON history(artist_id);
CREATE INDEX IF NOT EXISTS idx_history_song_id ON history(song_id);`)
if err != nil {
fmt.Fprintf(os.Stderr, "Error adding history entity columns: %v\n", err)
return err
}
return nil
}

621
db/entities.go Normal file
View File

@@ -0,0 +1,621 @@
package db
import (
"context"
"fmt"
"os"
"time"
"github.com/jackc/pgtype"
)
type Artist struct {
Id int
UserId int
Name string
ImageUrl string
Bio string
SpotifyId string
MusicbrainzId string
}
type Album struct {
Id int
UserId int
Title string
ArtistId int
CoverUrl string
SpotifyId string
MusicbrainzId string
}
type Song struct {
Id int
UserId int
Title string
ArtistId int
AlbumId int
DurationMs int
SpotifyId string
MusicbrainzId string
}
func GetOrCreateArtist(userId int, name string) (int, bool, error) {
if name == "" {
return 0, false, nil
}
var id int
err := Pool.QueryRow(context.Background(),
"SELECT id FROM artists WHERE user_id = $1 AND name = $2",
userId, name).Scan(&id)
if err == nil {
return id, false, nil
}
err = Pool.QueryRow(context.Background(),
`INSERT INTO artists (user_id, name) VALUES ($1, $2)
ON CONFLICT (user_id, name) DO UPDATE SET name = EXCLUDED.name
RETURNING id`,
userId, name).Scan(&id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating artist: %v\n", err)
return 0, false, err
}
return id, true, nil
}
func GetArtistById(id int) (Artist, error) {
var artist Artist
var imageUrlPg, bioPg, spotifyIdPg, musicbrainzIdPg pgtype.Text
err := Pool.QueryRow(context.Background(),
"SELECT id, user_id, name, image_url, bio, spotify_id, musicbrainz_id FROM artists WHERE id = $1",
id).Scan(&artist.Id, &artist.UserId, &artist.Name, &imageUrlPg, &bioPg, &spotifyIdPg, &musicbrainzIdPg)
if err != nil {
return Artist{}, err
}
if imageUrlPg.Status == pgtype.Present {
artist.ImageUrl = imageUrlPg.String
}
if bioPg.Status == pgtype.Present {
artist.Bio = bioPg.String
}
if spotifyIdPg.Status == pgtype.Present {
artist.SpotifyId = spotifyIdPg.String
}
if musicbrainzIdPg.Status == pgtype.Present {
artist.MusicbrainzId = musicbrainzIdPg.String
}
return artist, nil
}
func GetArtistByName(userId int, name string) (Artist, error) {
var artist Artist
var imageUrlPg, bioPg, spotifyIdPg, musicbrainzIdPg pgtype.Text
err := Pool.QueryRow(context.Background(),
"SELECT id, user_id, name, image_url, bio, spotify_id, musicbrainz_id FROM artists WHERE user_id = $1 AND name = $2",
userId, name).Scan(&artist.Id, &artist.UserId, &artist.Name, &imageUrlPg, &bioPg, &spotifyIdPg, &musicbrainzIdPg)
if err != nil {
return Artist{}, err
}
if imageUrlPg.Status == pgtype.Present {
artist.ImageUrl = imageUrlPg.String
}
if bioPg.Status == pgtype.Present {
artist.Bio = bioPg.String
}
if spotifyIdPg.Status == pgtype.Present {
artist.SpotifyId = spotifyIdPg.String
}
if musicbrainzIdPg.Status == pgtype.Present {
artist.MusicbrainzId = musicbrainzIdPg.String
}
return artist, nil
}
func UpdateArtist(id int, name, imageUrl, bio, spotifyId, musicbrainzId string) error {
_, err := Pool.Exec(context.Background(),
`UPDATE artists SET name = $1, image_url = $2, bio = $3, spotify_id = $4, musicbrainz_id = $5 WHERE id = $6`,
name, imageUrl, bio, spotifyId, musicbrainzId, id)
return err
}
func SearchArtists(userId int, query string) ([]Artist, error) {
likePattern := "%" + query + "%"
rows, err := Pool.Query(context.Background(),
`SELECT id, user_id, name, image_url, bio, spotify_id, musicbrainz_id
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
}
defer rows.Close()
var artists []Artist
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)
if err != nil {
return nil, err
}
if imageUrlPg.Status == pgtype.Present {
a.ImageUrl = imageUrlPg.String
}
if bioPg.Status == pgtype.Present {
a.Bio = bioPg.String
}
if spotifyIdPg.Status == pgtype.Present {
a.SpotifyId = spotifyIdPg.String
}
if musicbrainzIdPg.Status == pgtype.Present {
a.MusicbrainzId = musicbrainzIdPg.String
}
artists = append(artists, a)
}
return artists, nil
}
func GetOrCreateAlbum(userId int, title string, artistId int) (int, bool, error) {
if title == "" {
return 0, false, nil
}
var id int
err := Pool.QueryRow(context.Background(),
"SELECT id FROM albums WHERE user_id = $1 AND title = $2 AND (artist_id = $3 OR (artist_id IS NULL AND $3 IS NULL))",
userId, title, artistId).Scan(&id)
if err == nil {
return id, false, nil
}
err = Pool.QueryRow(context.Background(),
`INSERT INTO albums (user_id, title, artist_id) VALUES ($1, $2, $3)
ON CONFLICT (user_id, title, artist_id) DO UPDATE SET title = EXCLUDED.title
RETURNING id`,
userId, title, artistId).Scan(&id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating album: %v\n", err)
return 0, false, err
}
return id, true, nil
}
func GetAlbumById(id int) (Album, error) {
var album Album
err := Pool.QueryRow(context.Background(),
"SELECT id, user_id, title, artist_id, cover_url, spotify_id, musicbrainz_id FROM albums WHERE id = $1",
id).Scan(&album.Id, &album.UserId, &album.Title, &album.ArtistId, &album.CoverUrl,
&album.SpotifyId, &album.MusicbrainzId)
if err != nil {
return Album{}, err
}
return album, nil
}
func GetAlbumByName(userId int, title string, artistId int) (Album, error) {
var album Album
var artistIdVal int
var coverUrlPg, spotifyIdPg, musicbrainzIdPg pgtype.Text
var query string
var args []interface{}
if artistId > 0 {
query = `SELECT id, user_id, title, artist_id, cover_url, spotify_id, musicbrainz_id
FROM albums WHERE user_id = $1 AND title = $2 AND artist_id = $3`
args = []interface{}{userId, title, artistId}
} else {
query = `SELECT id, user_id, title, artist_id, cover_url, spotify_id, musicbrainz_id
FROM albums WHERE user_id = $1 AND title = $2`
args = []interface{}{userId, title}
}
err := Pool.QueryRow(context.Background(), query, args...).Scan(
&album.Id, &album.UserId, &album.Title, &artistIdVal, &coverUrlPg,
&spotifyIdPg, &musicbrainzIdPg)
if err != nil {
return Album{}, err
}
album.ArtistId = artistIdVal
if coverUrlPg.Status == pgtype.Present {
album.CoverUrl = coverUrlPg.String
}
if spotifyIdPg.Status == pgtype.Present {
album.SpotifyId = spotifyIdPg.String
}
if musicbrainzIdPg.Status == pgtype.Present {
album.MusicbrainzId = musicbrainzIdPg.String
}
return album, nil
}
func UpdateAlbum(id int, title, coverUrl, spotifyId, musicbrainzId string) error {
_, err := Pool.Exec(context.Background(),
`UPDATE albums SET title = $1, cover_url = $2, spotify_id = $3, musicbrainz_id = $4 WHERE id = $5`,
title, coverUrl, spotifyId, musicbrainzId, id)
return err
}
func SearchAlbums(userId int, query string) ([]Album, error) {
likePattern := "%" + query + "%"
rows, err := Pool.Query(context.Background(),
`SELECT id, user_id, title, artist_id, cover_url, spotify_id, musicbrainz_id
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
}
defer rows.Close()
var albums []Album
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)
if err != nil {
return nil, err
}
a.ArtistId = artistIdVal
if coverUrlPg.Status == pgtype.Present {
a.CoverUrl = coverUrlPg.String
}
if spotifyIdPg.Status == pgtype.Present {
a.SpotifyId = spotifyIdPg.String
}
if musicbrainzIdPg.Status == pgtype.Present {
a.MusicbrainzId = musicbrainzIdPg.String
}
albums = append(albums, a)
}
return albums, nil
}
func GetOrCreateSong(userId int, title string, artistId int, albumId int) (int, bool, error) {
if title == "" {
return 0, false, nil
}
var id int
err := Pool.QueryRow(context.Background(),
"SELECT id FROM songs WHERE user_id = $1 AND title = $2 AND (artist_id = $3 OR (artist_id IS NULL AND $3 IS NULL))",
userId, title, artistId).Scan(&id)
if err == nil {
return id, false, nil
}
err = Pool.QueryRow(context.Background(),
`INSERT INTO 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
RETURNING id`,
userId, title, artistId, albumId).Scan(&id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating song: %v\n", err)
return 0, false, err
}
return id, true, nil
}
func GetSongById(id int) (Song, error) {
var song Song
err := Pool.QueryRow(context.Background(),
"SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id FROM songs WHERE id = $1",
id).Scan(&song.Id, &song.UserId, &song.Title, &song.ArtistId, &song.AlbumId,
&song.DurationMs, &song.SpotifyId, &song.MusicbrainzId)
if err != nil {
return Song{}, err
}
return song, nil
}
func GetSongByName(userId int, title string, artistId int) (Song, error) {
var song Song
var artistIdVal, albumIdVal int
var durationMs *int
var spotifyIdPg, musicbrainzIdPg pgtype.Text
var query string
var args []interface{}
if artistId > 0 {
query = `SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id
FROM songs WHERE user_id = $1 AND title = $2 AND artist_id = $3`
args = []interface{}{userId, title, artistId}
} else {
query = `SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id
FROM songs WHERE user_id = $1 AND title = $2`
args = []interface{}{userId, title}
}
err := Pool.QueryRow(context.Background(), query, args...).Scan(
&song.Id, &song.UserId, &song.Title, &artistIdVal, &albumIdVal,
&durationMs, &spotifyIdPg, &musicbrainzIdPg)
if err != nil {
return Song{}, err
}
song.ArtistId = artistIdVal
song.AlbumId = albumIdVal
if durationMs != nil {
song.DurationMs = *durationMs
}
if spotifyIdPg.Status == pgtype.Present {
song.SpotifyId = spotifyIdPg.String
}
if musicbrainzIdPg.Status == pgtype.Present {
song.MusicbrainzId = musicbrainzIdPg.String
}
return song, nil
}
func UpdateSong(id int, title string, durationMs int, spotifyId, musicbrainzId string) error {
_, err := Pool.Exec(context.Background(),
`UPDATE songs SET title = $1, duration_ms = $2, spotify_id = $3, musicbrainz_id = $4 WHERE id = $5`,
title, durationMs, spotifyId, musicbrainzId, id)
return err
}
func SearchSongs(userId int, query string) ([]Song, error) {
likePattern := "%" + query + "%"
rows, err := Pool.Query(context.Background(),
`SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id
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
}
defer rows.Close()
var songs []Song
for rows.Next() {
var s Song
var artistIdVal, albumIdVal, durationMsVal int
var spotifyIdPg, musicbrainzIdPg pgtype.Text
err := rows.Scan(&s.Id, &s.UserId, &s.Title, &artistIdVal, &albumIdVal, &durationMsVal, &spotifyIdPg, &musicbrainzIdPg)
if err != nil {
return nil, err
}
s.ArtistId = artistIdVal
s.AlbumId = albumIdVal
s.DurationMs = durationMsVal
if spotifyIdPg.Status == pgtype.Present {
s.SpotifyId = spotifyIdPg.String
}
if musicbrainzIdPg.Status == pgtype.Present {
s.MusicbrainzId = musicbrainzIdPg.String
}
songs = append(songs, s)
}
return songs, nil
}
func GetArtistStats(userId, artistId int) (int, error) {
var count int
err := Pool.QueryRow(context.Background(),
"SELECT COUNT(*) FROM history WHERE user_id = $1 AND artist_id = $2",
userId, artistId).Scan(&count)
return count, err
}
func GetSongStats(userId, songId int) (int, error) {
var count int
err := Pool.QueryRow(context.Background(),
"SELECT COUNT(*) FROM history WHERE user_id = $1 AND song_id = $2",
userId, songId).Scan(&count)
return count, err
}
func MergeArtists(userId int, fromArtistId, toArtistId int) error {
_, err := Pool.Exec(context.Background(),
`UPDATE history SET artist_id = $1 WHERE user_id = $2 AND artist_id = $3`,
toArtistId, userId, fromArtistId)
if err != nil {
return err
}
_, err = Pool.Exec(context.Background(),
`UPDATE songs SET artist_id = $1 WHERE user_id = $2 AND artist_id = $3`,
toArtistId, userId, fromArtistId)
if err != nil {
return err
}
_, err = Pool.Exec(context.Background(),
`DELETE FROM artists WHERE id = $1 AND user_id = $2`,
fromArtistId, userId)
return err
}
func GetHistoryForArtist(userId, artistId int, limit, offset int) ([]ScrobbleEntry, error) {
rows, err := Pool.Query(context.Background(),
`SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform,
(SELECT name FROM artists WHERE id = h.artist_id) as artist_name
FROM history h WHERE h.user_id = $1 AND h.artist_id = $2
ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`,
userId, artistId, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ScrobbleEntry
for rows.Next() {
var e ScrobbleEntry
err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName)
if err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, nil
}
func GetHistoryForSong(userId, songId int, limit, offset int) ([]ScrobbleEntry, error) {
rows, err := Pool.Query(context.Background(),
`SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform,
(SELECT name FROM artists WHERE id = h.artist_id) as artist_name
FROM history h WHERE h.user_id = $1 AND h.song_id = $2
ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`,
userId, songId, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ScrobbleEntry
for rows.Next() {
var e ScrobbleEntry
err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName)
if err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, nil
}
type ScrobbleEntry struct {
Timestamp time.Time
SongName string
ArtistName string
AlbumName string
MsPlayed int
Platform string
}
func MigrateHistoryEntities() error {
rows, err := Pool.Query(context.Background(),
`SELECT DISTINCT h.user_id, h.artist, h.song_name, h.album_name
FROM history h
WHERE h.artist_id IS NULL OR h.song_id IS NULL`)
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching history for migration: %v\n", err)
return err
}
defer rows.Close()
type UniqueEntity struct {
UserId int
Artist string
SongName string
Album string
}
var entities []UniqueEntity
seen := make(map[string]bool)
for rows.Next() {
var r UniqueEntity
if err := rows.Scan(&r.UserId, &r.Artist, &r.SongName, &r.Album); err != nil {
continue
}
key := fmt.Sprintf("%d-%s-%s-%s", r.UserId, r.Artist, r.SongName, r.Album)
if !seen[key] {
seen[key] = true
entities = append(entities, r)
}
}
artistIds := make(map[string]int)
albumIds := make(map[string]int)
for _, e := range entities {
if e.Artist == "" {
continue
}
key := fmt.Sprintf("%d-%s", e.UserId, e.Artist)
if _, exists := artistIds[key]; !exists {
id, _, err := GetOrCreateArtist(e.UserId, e.Artist)
if err != nil {
continue
}
artistIds[key] = id
}
}
for _, e := range entities {
if e.Album == "" || e.Artist == "" {
continue
}
artistKey := fmt.Sprintf("%d-%s", e.UserId, e.Artist)
artistId, ok := artistIds[artistKey]
if !ok {
continue
}
albumKey := fmt.Sprintf("%d-%s-%d", e.UserId, e.Album, artistId)
if _, exists := albumIds[albumKey]; !exists {
id, _, err := GetOrCreateAlbum(e.UserId, e.Album, artistId)
if err != nil {
continue
}
albumIds[albumKey] = id
}
}
for _, e := range entities {
if e.SongName == "" || e.Artist == "" {
continue
}
artistKey := fmt.Sprintf("%d-%s", e.UserId, e.Artist)
artistId, ok := artistIds[artistKey]
if !ok {
continue
}
var albumId int
if e.Album != "" {
albumKey := fmt.Sprintf("%d-%s-%d", e.UserId, e.Album, artistId)
albumId = albumIds[albumKey]
}
songId, _, err := GetOrCreateSong(e.UserId, e.SongName, artistId, albumId)
if err != nil {
continue
}
_, err = Pool.Exec(context.Background(),
`UPDATE history SET artist_id = $1, song_id = $2
WHERE user_id = $3 AND artist = $4 AND song_name = $5
AND (artist_id IS NULL OR song_id IS NULL)`,
artistId, songId, e.UserId, e.Artist, e.SongName)
if err != nil {
fmt.Fprintf(os.Stderr, "Error updating history entity IDs: %v\n", err)
}
}
return nil
}
func GetAlbumStats(userId, albumId int) (int, error) {
var count int
err := Pool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM history h
JOIN songs s ON h.song_id = s.id
WHERE h.user_id = $1 AND s.album_id = $2`,
userId, albumId).Scan(&count)
return count, err
}
func GetHistoryForAlbum(userId, albumId int, limit, offset int) ([]ScrobbleEntry, error) {
rows, err := Pool.Query(context.Background(),
`SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform,
(SELECT name FROM artists WHERE id = h.artist_id) as artist_name
FROM history h
JOIN songs s ON h.song_id = s.id
WHERE h.user_id = $1 AND s.album_id = $2
ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`,
userId, albumId, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ScrobbleEntry
for rows.Next() {
var e ScrobbleEntry
err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName)
if err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, nil
}

View File

@@ -118,12 +118,33 @@ func SaveScrobble(scrobble Scrobble) error {
return fmt.Errorf("duplicate scrobble")
}
artistId, _, err := db.GetOrCreateArtist(scrobble.UserId, scrobble.Artist)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting/creating artist: %v\n", err)
return err
}
var albumId int
if scrobble.Album != "" {
albumId, _, err = db.GetOrCreateAlbum(scrobble.UserId, scrobble.Album, artistId)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting/creating album: %v\n", err)
return err
}
}
songId, _, err := db.GetOrCreateSong(scrobble.UserId, scrobble.SongName, artistId, albumId)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting/creating song: %v\n", err)
return err
}
_, err = db.Pool.Exec(context.Background(),
`INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform, artist_id, song_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (user_id, song_name, artist, timestamp) DO NOTHING`,
scrobble.UserId, scrobble.Timestamp, scrobble.SongName, scrobble.Artist,
scrobble.Album, scrobble.MsPlayed, scrobble.Platform)
scrobble.Album, scrobble.MsPlayed, scrobble.Platform, artistId, songId)
if err != nil {
fmt.Fprintf(os.Stderr, "Error saving scrobble: %v\n", err)
return err

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -29,4 +29,67 @@ document.addEventListener('DOMContentLoaded', function() {
closeMenu();
}
});
// Global Search
const searchInput = document.getElementById('globalSearch');
const searchResults = document.getElementById('searchResults');
let searchTimeout;
if (searchInput) {
searchInput.addEventListener('input', function(e) {
const query = e.target.value.trim();
clearTimeout(searchTimeout);
if (query.length < 2) {
searchResults.classList.remove('active');
searchResults.innerHTML = '';
return;
}
searchTimeout = setTimeout(function() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/search?q=' + encodeURIComponent(query), true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
var results = JSON.parse(xhr.responseText);
if (results.length === 0) {
searchResults.innerHTML = '<div class="search-result-item"><span class="search-result-name">No results</span></div>';
} else {
var html = '';
for (var i = 0; i < results.length; i++) {
var r = results[i];
html += '<a href="' + r.url + '" class="search-result-item">' +
'<div class="search-result-info">' +
'<span class="search-result-name">' + r.name + '</span>' +
'<span class="search-result-type">' + r.type + '</span>' +
'</div>' +
'<span class="search-result-count">' + r.count + '</span>' +
'</a>';
}
searchResults.innerHTML = html;
}
searchResults.classList.add('active');
}
}
};
xhr.send();
}, 300);
});
// 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');
}
});
}
});

View File

@@ -112,6 +112,93 @@
filter: invert(1) brightness(1.5);
}
/* Global Search */
.search-container {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1001;
width: 300px;
background: #222;
padding: 10px;
border-radius: 30px;
}
.search-container input {
width: 100%;
padding: 10px 15px;
border: 1px solid #444;
border-radius: 25px;
background: #333;
color: #AFA;
font-size: 14px;
outline: none;
box-sizing: border-box;
}
.search-container input:focus {
border-color: #AFA;
background: #444;
}
.search-results {
display: none;
position: absolute;
top: 100%;
left: 10px;
right: 10px;
background: #1a1a1a;
border: 1px solid #444;
border-radius: 8px;
margin-top: 5px;
max-height: 300px;
overflow-y: auto;
z-index: 1002;
}
.search-results.active {
display: block;
}
.search-result-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 15px;
cursor: pointer;
border-bottom: 1px solid #333;
text-decoration: none;
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover {
background: #333;
}
.search-result-info {
display: flex;
flex-direction: column;
}
.search-result-name {
color: #FFF;
font-size: 14px;
}
.search-result-type {
color: #888;
font-size: 12px;
}
.search-result-count {
color: #AFA;
font-size: 12px;
}
/* Menu Overlay */
.menu-overlay {
position: fixed;
@@ -124,11 +211,13 @@
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
pointer-events: none;
}
.menu-overlay.active {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.page_buttons {
@@ -227,6 +316,14 @@
tr:nth-child(even) {
background-color: #111;
}
a {
color: #AFA;
text-decoration: none;
}
a:hover {
color: #FFF;
text-decoration: underline;
}
}
.import-section {

58
templates/album.gohtml Normal file
View File

@@ -0,0 +1,58 @@
{{define "album"}}
<div class="profile-top">
{{if .Album.CoverUrl}}
<img src="{{.Album.CoverUrl}}" alt="{{.Album.Title}}'s cover">
{{else}}
<img src="/files/assets/pfps/default.png" alt="{{.Album.Title}}'s cover">
{{end}}
<div class="username-bio">
<h1>{{.Album.Title}}</h1>
{{if .Artist.Name}}
<h2><a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}">{{.Artist.Name}}</a></h2>
{{end}}
</div>
<div class="profile-top-blank">
</div>
<div class="user-stats-top">
<h3>{{formatInt .ListenCount}}</h3> <p>Listens<p>
</div>
</div>
{{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">
<h3>Scrobbles</h3>
<table>
<tr>
<th>Artist</th>
<th>Title</th>
<th>Album</th>
<th>Timestamp</th>
</tr>
{{$username := .Username}}
{{range .Times}}
<tr>
<td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td>
<td><a href="/profile/{{$username}}/song/{{urlquery .SongName}}">{{.SongName}}</a></td>
<td>{{.AlbumName}}</td>
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>
</tr>
{{end}}
</table>
</div>
<div class="page_buttons">
{{if gt .Page 1 }}
<a href="/profile/{{.Username}}/album/{{urlquery .Album.Title}}?page={{sub .Page 1}}">Prev Page</a>
{{end}}
<a href="/profile/{{.Username}}/album/{{urlquery .Album.Title}}?page={{add .Page 1}}">Next Page</a>
</div>
{{end}}

57
templates/artist.gohtml Normal file
View File

@@ -0,0 +1,57 @@
{{define "artist"}}
<div class="profile-top">
{{if .Artist.ImageUrl}}
<img src="{{.Artist.ImageUrl}}" alt="{{.Artist.Name}}'s image">
{{else}}
<img src="/files/assets/pfps/default_artist.png" alt="{{.Artist.Name}}'s image">
{{end}}
<div class="username-bio">
<h1>{{.Artist.Name}}</h1>
<h2>{{.Artist.Bio}}</h2>
</div>
<div class="profile-top-blank">
</div>
<div class="user-stats-top">
<h3>{{formatInt .ListenCount}}</h3> <p>Listens<p>
</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">
<h3>Scrobbles</h3>
<table>
<tr>
<th>Artist</th>
<th>Title</th>
<th>Album</th>
<th>Timestamp</th>
</tr>
{{$username := .Username}}
{{range .Times}}
<tr>
<td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td>
<td><a href="/profile/{{$username}}/song/{{urlquery .SongName}}">{{.SongName}}</a></td>
<td><a href="/profile/{{$username}}/album/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td>
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>
</tr>
{{end}}
</table>
</div>
<div class="page_buttons">
{{if gt .Page 1 }}
<a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}?page={{sub .Page 1}}">Prev Page</a>
{{end}}
<a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}?page={{add .Page 1}}">Next Page</a>
</div>
{{end}}

View File

@@ -13,6 +13,12 @@
<span></span>
<span></span>
</div>
<!-- Search Bar -->
<div class="search-container">
<input type="text" id="globalSearch" placeholder="Search artists, songs, albums..." autocomplete="off">
<div id="searchResults" class="search-results"></div>
</div>
<!-- Slide-out Menu -->
<div class="side-menu" id="sideMenu">
@@ -44,6 +50,9 @@
<!-- Main Content -->
{{ if eq .TemplateName "profile"}}{{block "profile" .}}{{end}}{{end}}
{{ if eq .TemplateName "settings"}}{{block "settings" .}}{{end}}{{end}}
{{ if eq .TemplateName "artist"}}{{block "artist" .}}{{end}}{{end}}
{{ if eq .TemplateName "song"}}{{block "song" .}}{{end}}{{end}}
{{ if eq .TemplateName "album"}}{{block "album" .}}{{end}}{{end}}
<script src="/files/menu.js"></script>
</body>

View File

@@ -7,10 +7,10 @@
</div>
<div class="profile-top-blank">
</div>
<div class="user-stats-top">
<h3>{{formatInt .ScrobbleCount}}</h3> <p>Listens<p>
<h3>{{formatInt .ArtistCount}}</h3> <p>Artists<p>
</div>
<div class="user-stats-top">
<h3>{{formatInt .ScrobbleCount}}</h3> <p>Listens<p>
<h3>{{formatInt .ArtistCount}}</h3> <p>Artists<p>
</div>
</div>
<div class="history">
<h3>Listening History</h3>
@@ -29,10 +29,11 @@
{{end}}
{{$artists := .Artists}}
{{$times := .Times}}
{{$username := .Username}}
{{range $index, $title := .Titles}}
<tr>
<td>{{index $artists $index}}</td>
<td>{{$title}}</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 title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</td>
</tr>
{{end}}

55
templates/song.gohtml Normal file
View File

@@ -0,0 +1,55 @@
{{define "song"}}
<div class="profile-top">
<div class="username-bio">
<h1>{{.Song.Title}}</h1>
{{if .Artist.Name}}
<h2><a href="/profile/{{.Username}}/artist/{{urlquery .Artist.Name}}">{{.Artist.Name}}</a></h2>
{{end}}
{{if .Album.Title}}
<h3><a href="/profile/{{.Username}}/album/{{urlquery .Album.Title}}">{{.Album.Title}}</a></h3>
{{end}}
</div>
<div class="profile-top-blank">
</div>
<div class="user-stats-top">
<h3>{{formatInt .ListenCount}}</h3> <p>Listens<p>
</div>
</div>
{{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">
<h3>Scrobbles</h3>
<table>
<tr>
<th>Artist</th>
<th>Title</th>
<th>Album</th>
<th>Timestamp</th>
</tr>
{{$username := .Username}}
{{range .Times}}
<tr>
<td><a href="/profile/{{$username}}/artist/{{urlquery .ArtistName}}">{{.ArtistName}}</a></td>
<td><a href="/profile/{{$username}}/song/{{urlquery .SongName}}">{{.SongName}}</a></td>
<td><a href="/profile/{{$username}}/album/{{urlquery .AlbumName}}">{{.AlbumName}}</a></td>
<td title="{{formatTimestampFull .Timestamp}}">{{formatTimestamp .Timestamp}}</td>
</tr>
{{end}}
</table>
</div>
<div class="page_buttons">
{{if gt .Page 1 }}
<a href="/profile/{{.Username}}/song/{{urlquery .Song.Title}}?page={{sub .Page 1}}">Prev Page</a>
{{end}}
<a href="/profile/{{.Username}}/song/{{urlquery .Song.Title}}?page={{add .Page 1}}">Next Page</a>
</div>
{{end}}

445
web/entity.go Normal file
View File

@@ -0,0 +1,445 @@
package web
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"muzi/db"
"github.com/go-chi/chi/v5"
)
type ArtistData struct {
Username string
Artist db.Artist
ListenCount int
Songs []string
Titles []string
Times []db.ScrobbleEntry
Page int
Title string
LoggedInUsername string
TemplateName string
}
type SongData struct {
Username string
Song db.Song
Artist db.Artist
Album db.Album
ListenCount int
Times []db.ScrobbleEntry
Page int
Title string
LoggedInUsername string
TemplateName string
}
type AlbumData struct {
Username string
Album db.Album
Artist db.Artist
ListenCount int
Times []db.ScrobbleEntry
Page int
Title string
LoggedInUsername string
TemplateName string
}
func artistPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
artistName, err := url.QueryUnescape(chi.URLParam(r, "artist"))
if err != nil {
http.Error(w, "Invalid artist name", http.StatusBadRequest)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
artist, err := db.GetArtistByName(userId, artistName)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot find artist %s: %v\n", artistName, err)
http.Error(w, "Artist not found", http.StatusNotFound)
return
}
pageStr := r.URL.Query().Get("page")
var pageInt int
if pageStr == "" {
pageInt = 1
} else {
pageInt, err = strconv.Atoi(pageStr)
if err != nil {
pageInt = 1
}
}
lim := 15
off := (pageInt - 1) * lim
listenCount, err := db.GetArtistStats(userId, artist.Id)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get artist stats: %v\n", err)
}
entries, err := db.GetHistoryForArtist(userId, artist.Id, lim, off)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get history for artist: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
artistData := ArtistData{
Username: username,
Artist: artist,
ListenCount: listenCount,
Times: entries,
Page: pageInt,
Title: artistName + " - " + username,
LoggedInUsername: getLoggedInUsername(r),
TemplateName: "artist",
}
err = templates.ExecuteTemplate(w, "base", artistData)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func songPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
songTitle, err := url.QueryUnescape(chi.URLParam(r, "song"))
if err != nil {
http.Error(w, "Invalid song title", http.StatusBadRequest)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
song, err := db.GetSongByName(userId, songTitle, 0)
if err != nil {
songs, searchErr := db.SearchSongs(userId, songTitle)
if searchErr == nil && len(songs) > 0 {
song = songs[0]
} else {
fmt.Fprintf(os.Stderr, "Cannot find song %s: %v\n", songTitle, err)
http.Error(w, "Song not found", http.StatusNotFound)
return
}
}
artist, _ := db.GetArtistById(song.ArtistId)
var album db.Album
if song.AlbumId > 0 {
album, _ = db.GetAlbumById(song.AlbumId)
}
pageStr := r.URL.Query().Get("page")
var pageInt int
if pageStr == "" {
pageInt = 1
} else {
pageInt, err = strconv.Atoi(pageStr)
if err != nil {
pageInt = 1
}
}
lim := 15
off := (pageInt - 1) * lim
listenCount, err := db.GetSongStats(userId, song.Id)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get song stats: %v\n", err)
}
entries, err := db.GetHistoryForSong(userId, song.Id, lim, off)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get history for song: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
songData := SongData{
Username: username,
Song: song,
Artist: artist,
Album: album,
ListenCount: listenCount,
Times: entries,
Page: pageInt,
Title: songTitle + " - " + username,
LoggedInUsername: getLoggedInUsername(r),
TemplateName: "song",
}
err = templates.ExecuteTemplate(w, "base", songData)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func editArtistHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
artistIdStr := chi.URLParam(r, "id")
artistId, err := strconv.Atoi(artistIdStr)
if err != nil {
http.Error(w, "Invalid artist ID", http.StatusBadRequest)
return
}
r.ParseForm()
name := r.Form.Get("name")
imageUrl := r.Form.Get("image_url")
bio := r.Form.Get("bio")
spotifyId := r.Form.Get("spotify_id")
musicbrainzId := r.Form.Get("musicbrainz_id")
err = db.UpdateArtist(artistId, name, imageUrl, bio, spotifyId, musicbrainzId)
if err != nil {
fmt.Fprintf(os.Stderr, "Error updating artist: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/artist/"+url.QueryEscape(name), http.StatusSeeOther)
}
}
func editSongHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
r.ParseForm()
title := r.Form.Get("title")
spotifyId := r.Form.Get("spotify_id")
musicbrainzId := r.Form.Get("musicbrainz_id")
songIdStr := chi.URLParam(r, "id")
songId, err := strconv.Atoi(songIdStr)
if err != nil {
http.Error(w, "Invalid song ID", http.StatusBadRequest)
return
}
err = db.UpdateSong(songId, title, 0, spotifyId, musicbrainzId)
if err != nil {
fmt.Fprintf(os.Stderr, "Error updating song: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/song/"+url.QueryEscape(title)+"?username="+username, http.StatusSeeOther)
}
}
func albumPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, "username")
albumTitle, err := url.QueryUnescape(chi.URLParam(r, "album"))
if err != nil {
http.Error(w, "Invalid album title", http.StatusBadRequest)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot find user %s: %v\n", username, err)
http.Error(w, "User not found", http.StatusNotFound)
return
}
album, err := db.GetAlbumByName(userId, albumTitle, 0)
if err != nil {
albums, searchErr := db.SearchAlbums(userId, albumTitle)
if searchErr == nil && len(albums) > 0 {
album = albums[0]
} else {
fmt.Fprintf(os.Stderr, "Cannot find album %s: %v\n", albumTitle, err)
http.Error(w, "Album not found", http.StatusNotFound)
return
}
}
artist, _ := db.GetArtistById(album.ArtistId)
pageStr := r.URL.Query().Get("page")
var pageInt int
if pageStr == "" {
pageInt = 1
} else {
pageInt, err = strconv.Atoi(pageStr)
if err != nil {
pageInt = 1
}
}
lim := 15
off := (pageInt - 1) * lim
listenCount, err := db.GetAlbumStats(userId, album.Id)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get album stats: %v\n", err)
}
entries, err := db.GetHistoryForAlbum(userId, album.Id, lim, off)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot get history for album: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
albumData := AlbumData{
Username: username,
Album: album,
Artist: artist,
ListenCount: listenCount,
Times: entries,
Page: pageInt,
Title: albumTitle + " - " + username,
LoggedInUsername: getLoggedInUsername(r),
TemplateName: "album",
}
err = templates.ExecuteTemplate(w, "base", albumData)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func editAlbumHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
r.ParseForm()
title := r.Form.Get("title")
coverUrl := r.Form.Get("cover_url")
spotifyId := r.Form.Get("spotify_id")
musicbrainzId := r.Form.Get("musicbrainz_id")
albumIdStr := chi.URLParam(r, "id")
albumId, err := strconv.Atoi(albumIdStr)
if err != nil {
http.Error(w, "Invalid album ID", http.StatusBadRequest)
return
}
err = db.UpdateAlbum(albumId, title, coverUrl, spotifyId, musicbrainzId)
if err != nil {
fmt.Fprintf(os.Stderr, "Error updating album: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/profile/"+username+"/album/"+url.QueryEscape(title), http.StatusSeeOther)
}
}
type SearchResult struct {
Type string `json:"type"`
Name string `json:"name"`
Url string `json:"url"`
Count int `json:"count"`
}
func searchHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := getLoggedInUsername(r)
if username == "" {
http.Error(w, "Not logged in", http.StatusUnauthorized)
return
}
userId, err := getUserIdByUsername(r.Context(), username)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
query := r.URL.Query().Get("q")
if len(query) < 2 {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("[]"))
return
}
var results []SearchResult
artists, err := db.SearchArtists(userId, query)
if err == nil {
for _, a := range artists {
count, _ := db.GetArtistStats(userId, a.Id)
results = append(results, SearchResult{
Type: "artist",
Name: a.Name,
Url: "/profile/" + username + "/artist/" + url.QueryEscape(a.Name),
Count: count,
})
}
}
songs, err := db.SearchSongs(userId, query)
if err == nil {
for _, s := range songs {
count, _ := db.GetSongStats(userId, s.Id)
results = append(results, SearchResult{
Type: "song",
Name: s.Title,
Url: "/profile/" + username + "/song/" + url.QueryEscape(s.Title),
Count: count,
})
}
}
albums, err := db.SearchAlbums(userId, query)
if err == nil {
for _, al := range albums {
count, _ := db.GetAlbumStats(userId, al.Id)
results = append(results, SearchResult{
Type: "album",
Name: al.Title,
Url: "/profile/" + username + "/album/" + url.QueryEscape(al.Title),
Count: count,
})
}
}
w.Header().Set("Content-Type", "application/json")
jsonBytes, _ := json.Marshal(results)
w.Write(jsonBytes)
}
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"muzi/config"
@@ -35,6 +36,7 @@ func init() {
"formatInt": formatInt,
"formatTimestamp": formatTimestamp,
"formatTimestampFull": formatTimestampFull,
"urlquery": url.QueryEscape,
}
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
}
@@ -81,6 +83,13 @@ func Start() {
r.Get("/login", loginPageHandler())
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.Post("/profile/{username}/artist/{id}/edit", editArtistHandler())
r.Post("/profile/{username}/song/{id}/edit", editSongHandler())
r.Post("/profile/{username}/album/{id}/edit", editAlbumHandler())
r.Get("/search", searchHandler())
r.Get("/import", importPageHandler())
r.Post("/loginsubmit", loginSubmit)
r.Post("/createaccountsubmit", createAccount)