Web UI: setup wizard, auth pages, library browsing, player, admin, streaming, cover art

This commit is contained in:
2026-08-22 23:42:09 -05:00
parent aa29f13846
commit e0bd8bc3cb
42 changed files with 3012 additions and 8 deletions
+227
View File
@@ -0,0 +1,227 @@
package web
import (
"context"
"net/http"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func (h *Web) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/users", http.StatusFound)
}
// --- Users ---
func (h *Web) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
users, err := database.ListUsers(h.DB)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.page(w, r, "admin_users", "admin_users", "Users", users)
}
func (h *Web) handleAdminUserCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
isAdmin := r.FormValue("is_admin") == "1"
if username == "" || len(password) < 8 {
http.Redirect(w, r, "/admin/users", http.StatusFound)
return
}
hash, err := auth.HashPassword(password)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if _, err := database.CreateUser(h.DB, username, "", hash, isAdmin); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users", http.StatusFound)
}
func (h *Web) handleAdminUserToggleDisabled(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
target, err := database.GetUserByID(h.DB, id)
if err != nil {
http.NotFound(w, r)
return
}
if err := database.SetUserDisabled(h.DB, id, !target.Disabled); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !target.Disabled { // just disabled it: revoke sessions
_ = database.DeleteUserSessions(h.DB, id)
}
http.Redirect(w, r, "/admin/users", http.StatusFound)
}
func (h *Web) handleAdminUserDelete(w http.ResponseWriter, r *http.Request) {
current := auth.UserFromContext(r.Context())
id := r.PathValue("id")
if id == current.ID {
http.Error(w, "cannot delete your own account", http.StatusBadRequest)
return
}
if err := database.DeleteUser(h.DB, id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users", http.StatusFound)
}
// --- Music folders ---
func (h *Web) handleAdminFolders(w http.ResponseWriter, r *http.Request) {
folders, err := database.ListMusicFolders(h.DB)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.page(w, r, "admin_folders", "admin_folders", "Music Folders", folders)
}
func (h *Web) handleAdminFolderCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
path := r.FormValue("path")
kind := r.FormValue("kind")
if kind == "" {
kind = "music"
}
if name == "" || path == "" {
http.Redirect(w, r, "/admin/folders", http.StatusFound)
return
}
folder, err := database.CreateMusicFolder(h.DB, name, path, kind)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if h.Scanner != nil {
go func() { _ = h.Scanner.ScanFolder(context.Background(), folder.ID) }()
}
http.Redirect(w, r, "/admin/folders", http.StatusFound)
}
func (h *Web) handleAdminFolderToggle(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
folder, err := database.GetMusicFolder(h.DB, id)
if err != nil {
http.NotFound(w, r)
return
}
if err := database.SetMusicFolderEnabled(h.DB, id, !folder.Enabled); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/folders", http.StatusFound)
}
func (h *Web) handleAdminFolderDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := database.DeleteMusicFolder(h.DB, id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/folders", http.StatusFound)
}
func (h *Web) handleAdminFolderScan(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := database.GetMusicFolder(h.DB, id); err != nil {
http.NotFound(w, r)
return
}
if h.Scanner != nil {
go func() { _ = h.Scanner.ScanFolder(context.Background(), id) }()
}
referer := r.Header.Get("Referer")
if referer == "" {
referer = "/admin/scanner"
}
http.Redirect(w, r, referer, http.StatusFound)
}
// --- Scanner status ---
type folderScanStatus struct {
Folder *database.MusicFolder
State *database.ScanState
}
func (h *Web) handleAdminScanner(w http.ResponseWriter, r *http.Request) {
folders, err := database.ListMusicFolders(h.DB)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var out []folderScanStatus
for _, f := range folders {
state, err := database.GetScanState(h.DB, f.ID)
if err != nil {
continue
}
out = append(out, folderScanStatus{Folder: f, State: state})
}
h.page(w, r, "admin_scanner", "admin_scanner", "Scanner", out)
}
// --- System settings ---
func (h *Web) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
h.renderAdminSettings(w, r, "", "")
}
func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash, kind string) {
trusted := ""
for i, p := range h.Config.ExtAuth.TrustedSources {
if i > 0 {
trusted += ", "
}
trusted += p.String()
}
h.flash(w, r, "admin_settings", "admin_settings", "System Settings", flash, kind, struct {
ScanInterval string
FFmpegPath string
FFprobePath string
ExtAuthEnabled bool
ExtAuthTrustedSources string
ExtAuthHeader string
}{
ScanInterval: database.GetSetting(h.DB, "scan_interval", h.Config.ScanInterval.String()),
FFmpegPath: database.GetSetting(h.DB, "ffmpeg_path", h.Config.FFmpegPath),
FFprobePath: database.GetSetting(h.DB, "ffprobe_path", h.Config.FFprobePath),
ExtAuthEnabled: h.Config.ExtAuth.Enabled,
ExtAuthTrustedSources: trusted,
ExtAuthHeader: h.Config.ExtAuth.UserHeader,
})
}
func (h *Web) handleAdminSettingsUpdate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
interval := r.FormValue("scan_interval")
if _, err := time.ParseDuration(interval); err != nil {
h.renderAdminSettings(w, r, "Invalid scan interval: "+err.Error(), "danger")
return
}
_ = database.SetSetting(h.DB, "scan_interval", interval)
_ = database.SetSetting(h.DB, "ffmpeg_path", r.FormValue("ffmpeg_path"))
_ = database.SetSetting(h.DB, "ffprobe_path", r.FormValue("ffprobe_path"))
h.renderAdminSettings(w, r, "Settings saved. Some changes take effect after a restart.", "success")
}
+176
View File
@@ -0,0 +1,176 @@
package web
import (
"context"
"net/http"
"os/exec"
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func (h *Web) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if h.Sessions.CurrentUser(r) != nil {
http.Redirect(w, r, "/", http.StatusFound)
return
}
render(w, "login", PageData{
Title: "Log in",
CSRFToken: h.CSRF.Token(w, r),
Data: struct{ Next string }{Next: r.URL.Query().Get("next")},
})
}
func (h *Web) handleLogin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
next := r.FormValue("next")
user, err := database.GetUserByUsername(h.DB, username)
if err != nil || user.Disabled || !user.PasswordHash.Valid {
h.loginFailed(w, r, next)
return
}
ok, err := auth.VerifyPassword(password, user.PasswordHash.String)
if err != nil || !ok {
h.loginFailed(w, r, next)
return
}
if err := h.Sessions.Issue(w, r, user.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_ = database.TouchLastLogin(h.DB, user.ID)
if next == "" || next[0] != '/' {
next = "/"
}
http.Redirect(w, r, next, http.StatusFound)
}
func (h *Web) loginFailed(w http.ResponseWriter, r *http.Request, next string) {
render(w, "login", PageData{
Title: "Log in",
CSRFToken: h.CSRF.Token(w, r),
Flash: "Invalid username or password",
FlashKind: "danger",
Data: struct{ Next string }{Next: next},
})
}
func (h *Web) handleLogout(w http.ResponseWriter, r *http.Request) {
h.Sessions.Clear(w, r)
http.Redirect(w, r, "/login", http.StatusFound)
}
// --- First-run setup ---
func (h *Web) handleSetupPage(w http.ResponseWriter, r *http.Request) {
n, err := database.CountUsers(h.DB)
if err == nil && n > 0 {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
render(w, "setup", PageData{
Title: "Setup",
CSRFToken: h.CSRF.Token(w, r),
Data: h.setupData(),
})
}
func (h *Web) setupData() any {
ffmpegPath := resolveOr(h.Config.FFmpegPath, "ffmpeg")
ffprobePath := resolveOr(h.Config.FFprobePath, "ffprobe")
_, ffmpegErr := exec.LookPath(ffmpegPath)
_, ffprobeErr := exec.LookPath(ffprobePath)
return struct {
FFmpegPath string
FFmpegFound bool
FFprobePath string
FFprobeFound bool
}{
FFmpegPath: ffmpegPath,
FFmpegFound: ffmpegErr == nil,
FFprobePath: ffprobePath,
FFprobeFound: ffprobeErr == nil,
}
}
func resolveOr(configured, def string) string {
if configured != "" {
return configured
}
return def
}
func (h *Web) handleSetup(w http.ResponseWriter, r *http.Request) {
n, err := database.CountUsers(h.DB)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if n > 0 {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
folderName := r.FormValue("folder_name")
folderPath := r.FormValue("folder_path")
if username == "" || len(password) < 8 || folderPath == "" {
render(w, "setup", PageData{
Title: "Setup", CSRFToken: h.CSRF.Token(w, r),
Flash: "Please fill in all fields (password must be at least 8 characters)", FlashKind: "danger",
Data: h.setupData(),
})
return
}
hash, err := auth.HashPassword(password)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
user, err := database.CreateUser(h.DB, username, "", hash, true)
if err != nil {
render(w, "setup", PageData{
Title: "Setup", CSRFToken: h.CSRF.Token(w, r),
Flash: "Could not create the administrator account: " + err.Error(), FlashKind: "danger",
Data: h.setupData(),
})
return
}
folder, err := database.CreateMusicFolder(h.DB, folderName, folderPath, "music")
if err != nil {
render(w, "setup", PageData{
Title: "Setup", CSRFToken: h.CSRF.Token(w, r),
Flash: "Administrator account created, but the music folder could not be added: " + err.Error(), FlashKind: "warning",
Data: h.setupData(),
})
return
}
if err := h.Sessions.Issue(w, r, user.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if h.Scanner != nil {
go func() {
_ = h.Scanner.ScanFolder(context.Background(), folder.ID)
}()
}
http.Redirect(w, r, "/", http.StatusFound)
}
+98
View File
@@ -0,0 +1,98 @@
package web
import (
"context"
"net/http"
"os"
"strconv"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
)
const maxCoverArtSize = 1200
// handleCoverArt serves a resized cover image, generating and caching it
// on first request. Authorization: the caller must already be signed in
// (enforced by the router's RequireUser wrapper on this route).
func (h *Web) handleCoverArt(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
size := clampSize(r.URL.Query().Get("size"))
art, err := database.GetCoverArt(h.DB, id)
if err != nil {
http.NotFound(w, r)
return
}
sourcePath, contentHash, isTemp, err := h.resolveCoverArtSource(r.Context(), art)
if err != nil {
http.Error(w, "cover art unavailable", http.StatusNotFound)
return
}
if isTemp {
defer os.Remove(sourcePath)
}
cachedPath, err := h.CoverArt.GetOrCreate(sourcePath, contentHash, size)
if err != nil {
http.Error(w, "could not generate cover art", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
if err := streaming.ServeFile(w, r, cachedPath, "cover.jpg"); err != nil {
http.Error(w, "could not read cover art", http.StatusInternalServerError)
}
}
// resolveCoverArtSource returns a plain image file path for a cover_art
// row, extracting embedded artwork to a temp file on demand. isTemp
// tells the caller whether it must remove the returned path afterward.
func (h *Web) resolveCoverArtSource(ctx context.Context, art *database.CoverArt) (path, contentHash string, isTemp bool, err error) {
if art.Source == "folder" {
return art.SourcePath, art.ContentHash, false, nil
}
// Embedded: extract the attached-picture stream from the source
// media file each time it's needed for resizing (the resize cache
// below avoids repeating this on every request).
mf, err := database.GetMediaFile(h.DB, art.MediaFileID.String)
if err != nil {
return "", "", false, err
}
folder, err := database.GetMusicFolder(h.DB, mf.MusicFolderID)
if err != nil {
return "", "", false, err
}
absPath, err := streaming.ResolvePath(folder.Path, mf.RelativePath)
if err != nil {
return "", "", false, err
}
prober := &metadata.Prober{FFprobePath: h.Config.FFprobePath}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
tags, err := prober.Probe(ctx, absPath)
if err != nil || !tags.HasEmbeddedArt {
return "", "", false, err
}
extracted, err := h.CoverArt.ExtractEmbedded(ctx, absPath, tags.EmbeddedArtStream)
if err != nil {
return "", "", false, err
}
return extracted, art.ContentHash, true, nil
}
func clampSize(raw string) int {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return 300
}
if n > maxCoverArtSize {
return maxCoverArtSize
}
return n
}
+17
View File
@@ -0,0 +1,17 @@
package web
import "net/http"
func (h *Web) handleHealthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
func (h *Web) handleReadyz(w http.ResponseWriter, r *http.Request) {
if err := h.DB.PingContext(r.Context()); err != nil {
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
+250
View File
@@ -0,0 +1,250 @@
package web
import (
"net/http"
"strconv"
"strings"
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
const pageSize = 60
func (h *Web) handleHome(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
albums, err := database.ListAlbums(h.DB, 12, 0)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var recentlyAdded []AlbumView
for _, a := range albums {
recentlyAdded = append(recentlyAdded, toAlbumView(h.DB, a))
}
var recentlyPlayedAlbums []AlbumView
if user != nil {
history, err := database.ListRecentlyPlayed(h.DB, user.ID, 30)
if err == nil {
seen := map[string]bool{}
for _, entry := range history {
song, err := database.GetSong(h.DB, entry.SongID)
if err != nil || !song.AlbumID.Valid || seen[song.AlbumID.String] {
continue
}
seen[song.AlbumID.String] = true
album, err := database.GetAlbum(h.DB, song.AlbumID.String)
if err != nil {
continue
}
recentlyPlayedAlbums = append(recentlyPlayedAlbums, toAlbumView(h.DB, album))
if len(recentlyPlayedAlbums) >= 12 {
break
}
}
}
}
hasLibrary := len(recentlyAdded) > 0
h.page(w, r, "home", "home", "Home", struct {
HasLibrary bool
RecentlyAdded []AlbumView
RecentlyPlayedAlbums []AlbumView
}{hasLibrary, recentlyAdded, recentlyPlayedAlbums})
}
func (h *Web) handleArtists(w http.ResponseWriter, r *http.Request) {
artists, err := database.ListArtists(h.DB)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var views []ArtistView
for _, a := range artists {
views = append(views, toArtistView(a))
}
h.page(w, r, "artists", "artists", "Artists", views)
}
func (h *Web) handleArtist(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
artist, err := database.GetArtist(h.DB, id)
if err != nil {
http.NotFound(w, r)
return
}
albums, err := database.ListAlbumsByArtist(h.DB, id)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var views []AlbumView
for _, a := range albums {
views = append(views, toAlbumView(h.DB, a))
}
h.page(w, r, "artist", "artists", artist.Name, struct {
Artist *database.Artist
Albums []AlbumView
}{artist, views})
}
func (h *Web) handleAlbums(w http.ResponseWriter, r *http.Request) {
offset := parseOffset(r)
albums, err := database.ListAlbums(h.DB, pageSize+1, offset)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
hasMore := len(albums) > pageSize
if hasMore {
albums = albums[:pageSize]
}
var views []AlbumView
for _, a := range albums {
views = append(views, toAlbumView(h.DB, a))
}
h.page(w, r, "albums", "albums", "Albums", struct {
Albums []AlbumView
HasMore bool
NextOffset int
}{views, hasMore, offset + pageSize})
}
func (h *Web) handleAlbum(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
id := r.PathValue("id")
album, err := database.GetAlbum(h.DB, id)
if err != nil {
http.NotFound(w, r)
return
}
songs, err := database.ListSongsByAlbum(h.DB, id)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
userID := ""
if user != nil {
userID = user.ID
}
var rows []SongRow
for _, s := range songs {
rows = append(rows, toSongRow(h.DB, s, album, userID))
}
starred := false
if user != nil {
starred, _ = database.IsStarred(h.DB, user.ID, "album", album.ID)
}
h.page(w, r, "album", "albums", album.Title, struct {
Album AlbumView
Songs []SongRow
Starred bool
}{toAlbumView(h.DB, album), rows, starred})
}
func (h *Web) handleSongs(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
offset := parseOffset(r)
songs, err := database.ListSongs(h.DB, pageSize+1, offset)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
hasMore := len(songs) > pageSize
if hasMore {
songs = songs[:pageSize]
}
userID := ""
if user != nil {
userID = user.ID
}
var rows []SongRow
for _, s := range songs {
rows = append(rows, toSongRow(h.DB, s, albumOf(h.DB, s), userID))
}
h.page(w, r, "songs", "songs", "Songs", struct {
Songs []SongRow
HasMore bool
NextOffset int
}{rows, hasMore, offset + pageSize})
}
func (h *Web) handleGenres(w http.ResponseWriter, r *http.Request) {
genres, err := database.ListGenres(h.DB)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.page(w, r, "genres", "genres", "Genres", genres)
}
func (h *Web) handleSearch(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
query := strings.TrimSpace(r.URL.Query().Get("q"))
var artistViews []ArtistView
var albumViews []AlbumView
var songRows []SongRow
if query != "" {
like := "%" + query + "%"
artistRows, _ := h.DB.Query(`SELECT DISTINCT a.id, a.name FROM artists a JOIN album_artists aa ON aa.artist_id = a.id WHERE a.name LIKE ? ORDER BY a.name LIMIT 20`, like)
if artistRows != nil {
defer artistRows.Close()
for artistRows.Next() {
var id, name string
if artistRows.Scan(&id, &name) == nil {
artistViews = append(artistViews, ArtistView{ID: id, Name: name})
}
}
}
albumRows, _ := h.DB.Query(`SELECT id FROM albums WHERE title LIKE ? ORDER BY title LIMIT 20`, like)
if albumRows != nil {
defer albumRows.Close()
for albumRows.Next() {
var id string
if albumRows.Scan(&id) == nil {
if album, err := database.GetAlbum(h.DB, id); err == nil {
albumViews = append(albumViews, toAlbumView(h.DB, album))
}
}
}
}
songIDRows, _ := h.DB.Query(`SELECT id FROM songs WHERE title LIKE ? ORDER BY title LIMIT 40`, like)
if songIDRows != nil {
defer songIDRows.Close()
userID := ""
if user != nil {
userID = user.ID
}
for songIDRows.Next() {
var id string
if songIDRows.Scan(&id) == nil {
if song, err := database.GetSong(h.DB, id); err == nil {
songRows = append(songRows, toSongRow(h.DB, song, albumOf(h.DB, song), userID))
}
}
}
}
}
h.page(w, r, "search", "search", "Search", struct {
Query string
Artists []ArtistView
Albums []AlbumView
Songs []SongRow
}{query, artistViews, albumViews, songRows})
}
func parseOffset(r *http.Request) int {
n, err := strconv.Atoi(r.URL.Query().Get("offset"))
if err != nil || n < 0 {
return 0
}
return n
}
+180
View File
@@ -0,0 +1,180 @@
package web
import (
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func (h *Web) handlePlaylists(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
playlists, err := database.ListPlaylistsVisibleToUser(h.DB, user.ID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.page(w, r, "playlists", "playlists", "Playlists", playlists)
}
func (h *Web) handlePlaylistCreate(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
if name == "" {
http.Redirect(w, r, "/playlists", http.StatusFound)
return
}
if _, err := database.CreatePlaylist(h.DB, user.ID, name, "", false); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/playlists", http.StatusFound)
}
func (h *Web) handlePlaylist(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
id := r.PathValue("id")
playlist, err := database.GetPlaylist(h.DB, id)
if err != nil {
http.NotFound(w, r)
return
}
if playlist.OwnerID != user.ID && !playlist.IsPublic {
http.Error(w, "403 Forbidden", http.StatusForbidden)
return
}
entries, err := database.ListPlaylistEntries(h.DB, id)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var rows []SongRow
for _, e := range entries {
song, err := database.GetSong(h.DB, e.SongID)
if err != nil {
continue
}
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
}
h.page(w, r, "playlist", "playlists", playlist.Name, struct {
Playlist *database.Playlist
Songs []SongRow
Owned bool
}{playlist, rows, playlist.OwnerID == user.ID})
}
func (h *Web) handlePlaylistDelete(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
id := r.PathValue("id")
playlist, err := database.GetPlaylist(h.DB, id)
if err != nil {
http.NotFound(w, r)
return
}
if playlist.OwnerID != user.ID {
http.Error(w, "403 Forbidden", http.StatusForbidden)
return
}
if err := database.DeletePlaylist(h.DB, id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/playlists", http.StatusFound)
}
func (h *Web) handleFavorites(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
artistIDs, _ := database.ListStarredIDs(h.DB, user.ID, "artist")
var artists []ArtistView
for _, id := range artistIDs {
if a, err := database.GetArtist(h.DB, id); err == nil {
artists = append(artists, toArtistView(a))
}
}
albumIDs, _ := database.ListStarredIDs(h.DB, user.ID, "album")
var albums []AlbumView
for _, id := range albumIDs {
if a, err := database.GetAlbum(h.DB, id); err == nil {
albums = append(albums, toAlbumView(h.DB, a))
}
}
songIDs, _ := database.ListStarredIDs(h.DB, user.ID, "song")
var songs []SongRow
for _, id := range songIDs {
if s, err := database.GetSong(h.DB, id); err == nil {
songs = append(songs, toSongRow(h.DB, s, albumOf(h.DB, s), user.ID))
}
}
h.page(w, r, "favorites", "favorites", "Favorites", struct {
Artists []ArtistView
Albums []AlbumView
Songs []SongRow
}{artists, albums, songs})
}
func (h *Web) handleFavoriteStar(w http.ResponseWriter, r *http.Request) { h.setFavorite(w, r, true) }
func (h *Web) handleFavoriteUnstar(w http.ResponseWriter, r *http.Request) {
h.setFavorite(w, r, false)
}
func (h *Web) setFavorite(w http.ResponseWriter, r *http.Request, star bool) {
user := auth.UserFromContext(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
itemType := r.FormValue("item_type")
itemID := r.FormValue("item_id")
next := r.FormValue("next")
if next == "" || next[0] != '/' {
next = "/"
}
switch itemType {
case "song", "album", "artist":
default:
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var err error
if star {
err = database.Star(h.DB, user.ID, itemType, itemID)
} else {
err = database.Unstar(h.DB, user.ID, itemType, itemID)
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, next, http.StatusFound)
}
func (h *Web) handleRecent(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
history, err := database.ListRecentlyPlayed(h.DB, user.ID, 100)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var rows []SongRow
seen := map[string]bool{}
for _, entry := range history {
if seen[entry.SongID] {
continue
}
seen[entry.SongID] = true
song, err := database.GetSong(h.DB, entry.SongID)
if err != nil {
continue
}
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
}
h.page(w, r, "recent", "recent", "Recently Played", rows)
}
+59
View File
@@ -0,0 +1,59 @@
package web
import (
"fmt"
"html/template"
"net/http"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
assets "git.noroutes.net/NoRoutes/vibetunes/web"
)
var funcMap = template.FuncMap{
"fmtDuration": func(seconds float64) string {
if seconds <= 0 {
return "-"
}
d := time.Duration(seconds) * time.Second
m := int(d.Minutes())
s := int(d.Seconds()) % 60
return fmt.Sprintf("%d:%02d", m, s)
},
"fmtTime": func(t time.Time) string {
if t.IsZero() {
return "-"
}
return t.Format("2006-01-02 15:04")
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
}
var templates *template.Template
func init() {
t, err := template.New("").Funcs(funcMap).ParseFS(assets.FS, "templates/*.html", "templates/partials/*.html")
if err != nil {
panic(err)
}
templates = t
}
// PageData is the common envelope passed to every page template.
type PageData struct {
Active string // nav item key for sidebar highlighting
Title string
User *database.User
CSRFToken string
Flash string
FlashKind string // success|danger|warning
Data any
}
func render(w http.ResponseWriter, name string, data PageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := templates.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
+105
View File
@@ -0,0 +1,105 @@
package web
import (
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
assets "git.noroutes.net/NoRoutes/vibetunes/web"
)
// NewRouter builds VibeSonic's full HTTP handler: static assets, health
// checks, first-run setup, authentication, and the authenticated
// application (library browsing, playback, playlists, favorites,
// settings, admin).
func (h *Web) NewRouter() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServer(http.FS(assets.FS)))
mux.HandleFunc("GET /healthz", h.handleHealthz)
mux.HandleFunc("GET /readyz", h.handleReadyz)
mux.HandleFunc("GET /setup", h.handleSetupPage)
mux.HandleFunc("POST /setup", h.handleSetup)
mux.HandleFunc("GET /login", h.handleLoginPage)
mux.HandleFunc("POST /login", h.handleLogin)
mux.HandleFunc("POST /logout", h.handleLogout)
mux.HandleFunc("GET /{$}", h.requireUser(h.handleHome))
mux.HandleFunc("GET /search", h.requireUser(h.handleSearch))
mux.HandleFunc("GET /artists", h.requireUser(h.handleArtists))
mux.HandleFunc("GET /artists/{id}", h.requireUser(h.handleArtist))
mux.HandleFunc("GET /albums", h.requireUser(h.handleAlbums))
mux.HandleFunc("GET /albums/{id}", h.requireUser(h.handleAlbum))
mux.HandleFunc("GET /songs", h.requireUser(h.handleSongs))
mux.HandleFunc("GET /genres", h.requireUser(h.handleGenres))
mux.HandleFunc("GET /playlists", h.requireUser(h.handlePlaylists))
mux.HandleFunc("POST /playlists", h.requireUser(h.handlePlaylistCreate))
mux.HandleFunc("GET /playlists/{id}", h.requireUser(h.handlePlaylist))
mux.HandleFunc("POST /playlists/{id}/delete", h.requireUser(h.handlePlaylistDelete))
mux.HandleFunc("GET /favorites", h.requireUser(h.handleFavorites))
mux.HandleFunc("POST /favorites/star", h.requireUser(h.handleFavoriteStar))
mux.HandleFunc("POST /favorites/unstar", h.requireUser(h.handleFavoriteUnstar))
mux.HandleFunc("GET /recent", h.requireUser(h.handleRecent))
mux.HandleFunc("GET /settings", h.requireUser(h.handleSettings))
mux.HandleFunc("POST /settings/password", h.requireUser(h.handlePasswordChange))
mux.HandleFunc("POST /settings/api-keys", h.requireUser(h.handleAPIKeyCreate))
mux.HandleFunc("POST /settings/api-keys/{id}/revoke", h.requireUser(h.handleAPIKeyRevoke))
mux.HandleFunc("POST /settings/sessions/{id}/revoke", h.requireUser(h.handleSessionRevoke))
mux.HandleFunc("GET /stream/{id}", h.requireUser(h.handleStream))
mux.HandleFunc("GET /cover-art/{id}", h.requireUser(h.handleCoverArt))
mux.HandleFunc("GET /admin", h.requireAdmin(h.handleAdminRoot))
mux.HandleFunc("GET /admin/users", h.requireAdmin(h.handleAdminUsers))
mux.HandleFunc("POST /admin/users", h.requireAdmin(h.handleAdminUserCreate))
mux.HandleFunc("POST /admin/users/{id}/toggle-disabled", h.requireAdmin(h.handleAdminUserToggleDisabled))
mux.HandleFunc("POST /admin/users/{id}/delete", h.requireAdmin(h.handleAdminUserDelete))
mux.HandleFunc("GET /admin/folders", h.requireAdmin(h.handleAdminFolders))
mux.HandleFunc("POST /admin/folders", h.requireAdmin(h.handleAdminFolderCreate))
mux.HandleFunc("POST /admin/folders/{id}/toggle", h.requireAdmin(h.handleAdminFolderToggle))
mux.HandleFunc("POST /admin/folders/{id}/delete", h.requireAdmin(h.handleAdminFolderDelete))
mux.HandleFunc("POST /admin/folders/{id}/scan", h.requireAdmin(h.handleAdminFolderScan))
mux.HandleFunc("GET /admin/scanner", h.requireAdmin(h.handleAdminScanner))
mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings))
mux.HandleFunc("POST /admin/settings", h.requireAdmin(h.handleAdminSettingsUpdate))
var handler http.Handler = mux
handler = h.CSRF.Protect(handler)
handler = h.setupGate(handler)
handler = h.Middleware.Authenticate(handler)
return handler
}
func (h *Web) requireUser(fn http.HandlerFunc) http.HandlerFunc {
wrapped := h.Middleware.RequireUser(fn)
return wrapped.ServeHTTP
}
func (h *Web) requireAdmin(fn http.HandlerFunc) http.HandlerFunc {
wrapped := h.Middleware.RequireAdmin(fn)
return wrapped.ServeHTTP
}
// setupGate sends every request to /setup until an administrator
// account exists, so a fresh install can never be used half-configured.
func (h *Web) setupGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/setup" || r.URL.Path == "/healthz" || r.URL.Path == "/readyz" ||
len(r.URL.Path) >= 8 && r.URL.Path[:8] == "/static/" {
next.ServeHTTP(w, r)
return
}
n, err := database.CountUsers(h.DB)
if err == nil && n == 0 {
http.Redirect(w, r, "/setup", http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
+119
View File
@@ -0,0 +1,119 @@
package web
import (
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func (h *Web) handleSettings(w http.ResponseWriter, r *http.Request) {
h.renderSettings(w, r, "", "", "")
}
func (h *Web) renderSettings(w http.ResponseWriter, r *http.Request, newAPIKey, flash, flashKind string) {
user := auth.UserFromContext(r.Context())
keys, err := database.ListAPIKeysForUser(h.DB, user.ID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
sessions, err := database.ListUserSessions(h.DB, user.ID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.flash(w, r, "settings", "", "Settings", flash, flashKind, struct {
APIKeys []*database.APIKey
Sessions []*database.Session
NewAPIKey string
}{keys, sessions, newAPIKey})
}
func (h *Web) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
current := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
if !user.PasswordHash.Valid {
h.renderSettings(w, r, "", "This account has no local password to change (it signs in via external authentication).", "danger")
return
}
ok, err := auth.VerifyPassword(current, user.PasswordHash.String)
if err != nil || !ok {
h.renderSettings(w, r, "", "Current password is incorrect.", "danger")
return
}
if len(newPassword) < 8 {
h.renderSettings(w, r, "", "New password must be at least 8 characters.", "danger")
return
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := database.SetUserPassword(h.DB, user.ID, hash); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_ = database.DeleteUserSessions(h.DB, user.ID)
if err := h.Sessions.Issue(w, r, user.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderSettings(w, r, "", "Password updated.", "success")
}
func (h *Web) handleAPIKeyCreate(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
rawKey, err := auth.GenerateAPIKey()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if _, err := database.CreateAPIKey(h.DB, user.ID, name, auth.HashAPIKey(rawKey)); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderSettings(w, r, rawKey, "", "")
}
func (h *Web) handleAPIKeyRevoke(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
id := r.PathValue("id")
key, err := database.GetAPIKey(h.DB, id)
if err != nil || key.UserID != user.ID {
http.NotFound(w, r)
return
}
if err := database.RevokeAPIKey(h.DB, id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/settings", http.StatusFound)
}
func (h *Web) handleSessionRevoke(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
id := r.PathValue("id")
sess, err := database.GetSession(h.DB, id)
if err != nil || sess.UserID != user.ID {
http.NotFound(w, r)
return
}
if err := database.DeleteSession(h.DB, id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/settings", http.StatusFound)
}
+56
View File
@@ -0,0 +1,56 @@
package web
import (
"net/http"
"strings"
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
)
// handleStream serves a song's audio bytes directly (no transcoding in
// Phase 1). Authorization happens here, before any file is opened: the
// caller must be a signed-in, non-disabled user (enforced by the
// RequireUser middleware wrapping this route), and the resolved path is
// always re-validated against its music folder root.
func (h *Web) handleStream(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
songID := r.PathValue("id")
song, err := database.GetSong(h.DB, songID)
if err != nil {
http.NotFound(w, r)
return
}
mediaFile, err := database.GetMediaFile(h.DB, song.MediaFileID)
if err != nil {
http.NotFound(w, r)
return
}
folder, err := database.GetMusicFolder(h.DB, mediaFile.MusicFolderID)
if err != nil {
http.NotFound(w, r)
return
}
absPath, err := streaming.ResolvePath(folder.Path, mediaFile.RelativePath)
if err != nil {
http.Error(w, "403 Forbidden", http.StatusForbidden)
return
}
// Best-effort playback tracking: only record once per playback (on
// the request for the start of the file, not every range chunk a
// browser issues while seeking/buffering).
rangeHeader := r.Header.Get("Range")
if rangeHeader == "" || strings.HasPrefix(rangeHeader, "bytes=0-") {
_ = database.SetNowPlaying(h.DB, user.ID, song.ID, "web", 0)
_ = database.RecordPlayback(h.DB, user.ID, song.ID, 0)
}
if err := streaming.ServeFile(w, r, absPath, mediaFile.RelativePath); err != nil {
http.Error(w, "could not read media file", http.StatusInternalServerError)
return
}
}
+111
View File
@@ -0,0 +1,111 @@
package web
import (
"database/sql"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// View-model structs shape database rows for templates, keeping SQL
// scanning details (sql.Null*) out of the .html files.
type ArtistView struct {
ID string
Name string
}
type AlbumView struct {
ID string
Title string
ArtistName string
Year int64
CoverArtID string
}
type SongRow struct {
ID string
Title string
ArtistName string
AlbumTitle string
AlbumID string
CoverArtID string
TrackNumber int64
DurationSeconds float64
Starred bool
}
func toArtistView(a *database.Artist) ArtistView {
return ArtistView{ID: a.ID, Name: a.Name}
}
func toAlbumView(db *sql.DB, a *database.Album) AlbumView {
v := AlbumView{ID: a.ID, Title: a.Title}
if a.Year.Valid {
v.Year = a.Year.Int64
}
if a.CoverArtID.Valid {
v.CoverArtID = a.CoverArtID.String
}
if a.PrimaryAlbumArtistID.Valid {
if artist, err := database.GetArtist(db, a.PrimaryAlbumArtistID.String); err == nil {
v.ArtistName = artist.Name
}
}
return v
}
// toSongRow builds a display row for a song, given its owning album (may
// be nil for albumless tracks) and the current user (for the starred
// flag; pass "" userID to skip that lookup).
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string) SongRow {
row := SongRow{
ID: s.ID,
Title: s.Title,
DurationSeconds: 0,
}
if s.TrackNumber.Valid {
row.TrackNumber = s.TrackNumber.Int64
}
if names, err := database.ListSongArtistNames(db, s.ID); err == nil && len(names) > 0 {
row.ArtistName = names[0]
for _, n := range names[1:] {
row.ArtistName += ", " + n
}
}
if album != nil {
row.AlbumTitle = album.Title
row.AlbumID = album.ID
if album.CoverArtID.Valid {
row.CoverArtID = album.CoverArtID.String
}
}
if mf, err := database.GetMediaFile(db, s.MediaFileID); err == nil {
row.DurationSeconds = mf.DurationSeconds
}
if userID != "" {
if starred, err := database.IsStarred(db, userID, "song", s.ID); err == nil {
row.Starred = starred
}
}
return row
}
// albumOf loads a song's album, tolerating albumless songs.
func albumOf(db *sql.DB, s *database.Song) *database.Album {
if !s.AlbumID.Valid {
return nil
}
album, err := database.GetAlbum(db, s.AlbumID.String)
if err != nil {
return nil
}
return album
}
type SessionView struct {
ID string
UserAgent string
IPAddress string
CreatedAt time.Time
}
+49
View File
@@ -0,0 +1,49 @@
// Package web implements VibeSonic's server-rendered Bootstrap 5 UI:
// setup, authentication, library browsing, playback, and admin pages.
package web
import (
"database/sql"
"log/slog"
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
)
type Web struct {
DB *sql.DB
Config config.Config
Sessions *auth.Sessions
CSRF *auth.CSRF
Middleware *auth.Middleware
Scanner *scanner.Scanner
CoverArt *coverart.Store
Log *slog.Logger
}
// page renders a template with the standard envelope (current user, CSRF
// token, active nav item) filled in.
func (h *Web) page(w http.ResponseWriter, r *http.Request, name, active, title string, data any) {
render(w, name, PageData{
Active: active,
Title: title,
User: auth.UserFromContext(r.Context()),
CSRFToken: h.CSRF.Token(w, r),
Data: data,
})
}
func (h *Web) flash(w http.ResponseWriter, r *http.Request, name, active, title, flash, kind string, data any) {
render(w, name, PageData{
Active: active,
Title: title,
User: auth.UserFromContext(r.Context()),
CSRFToken: h.CSRF.Token(w, r),
Flash: flash,
FlashKind: kind,
Data: data,
})
}