Web UI: podcasts, radio, shares, bookmarks pages; admin podcast/jukebox management; per-user Subsonic permission editor

This commit is contained in:
2026-08-23 00:34:58 -05:00
parent 2e222bd193
commit f1fdd8d2c2
21 changed files with 688 additions and 9 deletions
+41
View File
@@ -48,6 +48,47 @@ func (h *Web) handleAdminUserCreate(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/users", http.StatusFound)
}
func (h *Web) handleAdminUserPermissions(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
}
h.page(w, r, "admin_user_permissions", "admin_users", "Permissions: "+target.Username, target)
}
func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := database.GetUserByID(h.DB, id); err != nil {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
get := func(name string) bool { return r.FormValue(name) == "1" }
perms := database.Permissions{
Settings: get("settings"),
Stream: get("stream"),
Download: get("download"),
Upload: get("upload"),
Playlist: get("playlist"),
CoverArt: get("cover_art"),
Comment: get("comment"),
Podcast: get("podcast"),
Share: get("share"),
Jukebox: get("jukebox"),
VideoConversion: get("video_conversion"),
}
if err := database.SetUserPermissions(h.DB, id, perms); 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)
+42
View File
@@ -0,0 +1,42 @@
package web
import "net/http"
func (h *Web) handleAdminJukebox(w http.ResponseWriter, r *http.Request) {
data := struct {
Available bool
Status any
Error string
}{Available: h.Jukebox.Available}
if h.Jukebox.Available {
state, err := h.Jukebox.Status()
if err != nil {
data.Error = err.Error()
} else {
data.Status = state
}
}
h.page(w, r, "admin_jukebox", "admin_jukebox", "Jukebox", data)
}
func (h *Web) handleAdminJukeboxStop(w http.ResponseWriter, r *http.Request) {
if h.Jukebox.Available {
_, _ = h.Jukebox.Stop()
}
http.Redirect(w, r, "/admin/jukebox", http.StatusFound)
}
func (h *Web) handleAdminJukeboxStart(w http.ResponseWriter, r *http.Request) {
if h.Jukebox.Available {
_, _ = h.Jukebox.Start()
}
http.Redirect(w, r, "/admin/jukebox", http.StatusFound)
}
func (h *Web) handleAdminJukeboxClear(w http.ResponseWriter, r *http.Request) {
if h.Jukebox.Available {
_, _ = h.Jukebox.Clear()
}
http.Redirect(w, r, "/admin/jukebox", http.StatusFound)
}
+83
View File
@@ -0,0 +1,83 @@
package web
import (
"context"
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func (h *Web) handleAdminPodcasts(w http.ResponseWriter, r *http.Request) {
channels, err := database.ListPodcastChannels(h.DB)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
type channelView struct {
Channel *database.PodcastChannel
Episodes []*database.PodcastEpisode
}
var views []channelView
for _, c := range channels {
episodes, _ := database.ListPodcastEpisodes(h.DB, c.ID)
views = append(views, channelView{Channel: c, Episodes: episodes})
}
h.page(w, r, "admin_podcasts", "admin_podcasts", "Podcasts", views)
}
func (h *Web) handleAdminPodcastCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
feedURL := r.FormValue("url")
if feedURL == "" {
http.Redirect(w, r, "/admin/podcasts", http.StatusFound)
return
}
channel, err := database.CreatePodcastChannel(h.DB, feedURL)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
go func() { _ = h.Podcasts.RefreshChannel(context.Background(), channel.ID) }()
http.Redirect(w, r, "/admin/podcasts", http.StatusFound)
}
func (h *Web) handleAdminPodcastRefresh(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := database.GetPodcastChannel(h.DB, id); err != nil {
http.NotFound(w, r)
return
}
go func() { _ = h.Podcasts.RefreshChannel(context.Background(), id) }()
http.Redirect(w, r, "/admin/podcasts", http.StatusFound)
}
func (h *Web) handleAdminPodcastDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := database.DeletePodcastChannel(h.DB, id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/podcasts", http.StatusFound)
}
func (h *Web) handleAdminPodcastEpisodeDownload(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := database.GetPodcastEpisode(h.DB, id); err != nil {
http.NotFound(w, r)
return
}
go func() { _ = h.Podcasts.DownloadEpisode(context.Background(), id) }()
http.Redirect(w, r, "/admin/podcasts", http.StatusFound)
}
func (h *Web) handleAdminPodcastEpisodeDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := database.DeletePodcastEpisode(h.DB, id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/podcasts", http.StatusFound)
}
+40
View File
@@ -0,0 +1,40 @@
package web
import (
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func (h *Web) handleBookmarks(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
bookmarks, err := database.ListBookmarks(h.DB, user.ID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
type bookmarkView struct {
Bookmark *database.Bookmark
Song SongRow
}
var views []bookmarkView
for _, b := range bookmarks {
song, err := database.GetSong(h.DB, b.SongID)
if err != nil {
continue
}
views = append(views, bookmarkView{Bookmark: b, Song: toSongRow(h.DB, song, albumOf(h.DB, song), user.ID)})
}
h.page(w, r, "bookmarks", "bookmarks", "Bookmarks", views)
}
func (h *Web) handleBookmarkDelete(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
songID := r.PathValue("songID")
if err := database.DeleteBookmark(h.DB, user.ID, songID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/bookmarks", http.StatusFound)
}
+36
View File
@@ -0,0 +1,36 @@
package web
import (
"context"
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func (h *Web) handlePodcasts(w http.ResponseWriter, r *http.Request) {
channels, err := database.ListPodcastChannels(h.DB)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
type channelView struct {
Channel *database.PodcastChannel
Episodes []*database.PodcastEpisode
}
var views []channelView
for _, c := range channels {
episodes, _ := database.ListPodcastEpisodes(h.DB, c.ID)
views = append(views, channelView{Channel: c, Episodes: episodes})
}
h.page(w, r, "podcasts", "podcasts", "Podcasts", views)
}
func (h *Web) handlePodcastDownload(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := database.GetPodcastEpisode(h.DB, id); err != nil {
http.NotFound(w, r)
return
}
go func() { _ = h.Podcasts.DownloadEpisode(context.Background(), id) }()
http.Redirect(w, r, "/podcasts", http.StatusFound)
}
+44
View File
@@ -0,0 +1,44 @@
package web
import (
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func (h *Web) handleRadio(w http.ResponseWriter, r *http.Request) {
stations, err := database.ListRadioStations(h.DB)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.page(w, r, "radio", "radio", "Internet Radio", stations)
}
func (h *Web) handleRadioCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
streamURL := r.FormValue("stream_url")
homepage := r.FormValue("homepage_url")
if name == "" || streamURL == "" {
http.Redirect(w, r, "/radio", http.StatusFound)
return
}
if _, err := database.CreateRadioStation(h.DB, name, streamURL, homepage); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/radio", http.StatusFound)
}
func (h *Web) handleRadioDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := database.DeleteRadioStation(h.DB, id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/radio", http.StatusFound)
}
+3 -2
View File
@@ -26,8 +26,9 @@ var funcMap = template.FuncMap{
}
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 },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"millisToSec": func(ms int) float64 { return float64(ms) / 1000 },
}
var templates *template.Template
+28
View File
@@ -48,6 +48,20 @@ func (h *Web) NewRouter() http.Handler {
mux.HandleFunc("POST /favorites/unstar", h.requireUser(h.handleFavoriteUnstar))
mux.HandleFunc("GET /recent", h.requireUser(h.handleRecent))
mux.HandleFunc("GET /podcasts", h.requireUser(h.handlePodcasts))
mux.HandleFunc("POST /podcasts/{id}/download", h.requireUser(h.handlePodcastDownload))
mux.HandleFunc("GET /radio", h.requireUser(h.handleRadio))
mux.HandleFunc("POST /radio", h.requireAdmin(h.handleRadioCreate))
mux.HandleFunc("POST /radio/{id}/delete", h.requireAdmin(h.handleRadioDelete))
mux.HandleFunc("GET /shares", h.requireUser(h.handleShares))
mux.HandleFunc("POST /albums/{id}/share", h.requireUser(h.handleShareCreateForAlbum))
mux.HandleFunc("POST /shares/{id}/delete", h.requireUser(h.handleShareDelete))
mux.HandleFunc("GET /bookmarks", h.requireUser(h.handleBookmarks))
mux.HandleFunc("POST /bookmarks/{songID}/delete", h.requireUser(h.handleBookmarkDelete))
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))
@@ -62,6 +76,8 @@ func (h *Web) NewRouter() http.Handler {
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/users/{id}/permissions", h.requireAdmin(h.handleAdminUserPermissions))
mux.HandleFunc("POST /admin/users/{id}/permissions", h.requireAdmin(h.handleAdminUserPermissionsUpdate))
mux.HandleFunc("GET /admin/folders", h.requireAdmin(h.handleAdminFolders))
mux.HandleFunc("POST /admin/folders", h.requireAdmin(h.handleAdminFolderCreate))
@@ -73,6 +89,18 @@ func (h *Web) NewRouter() http.Handler {
mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings))
mux.HandleFunc("POST /admin/settings", h.requireAdmin(h.handleAdminSettingsUpdate))
mux.HandleFunc("GET /admin/podcasts", h.requireAdmin(h.handleAdminPodcasts))
mux.HandleFunc("POST /admin/podcasts", h.requireAdmin(h.handleAdminPodcastCreate))
mux.HandleFunc("POST /admin/podcasts/{id}/refresh", h.requireAdmin(h.handleAdminPodcastRefresh))
mux.HandleFunc("POST /admin/podcasts/{id}/delete", h.requireAdmin(h.handleAdminPodcastDelete))
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/download", h.requireAdmin(h.handleAdminPodcastEpisodeDownload))
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/delete", h.requireAdmin(h.handleAdminPodcastEpisodeDelete))
mux.HandleFunc("GET /admin/jukebox", h.requireAdmin(h.handleAdminJukebox))
mux.HandleFunc("POST /admin/jukebox/start", h.requireAdmin(h.handleAdminJukeboxStart))
mux.HandleFunc("POST /admin/jukebox/stop", h.requireAdmin(h.handleAdminJukeboxStop))
mux.HandleFunc("POST /admin/jukebox/clear", h.requireAdmin(h.handleAdminJukeboxClear))
var handler http.Handler = mux
handler = h.CSRF.Protect(handler)
handler = h.setupGate(handler)
+66
View File
@@ -0,0 +1,66 @@
package web
import (
"database/sql"
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
func (h *Web) handleShares(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
var shares []*database.Share
var err error
if user.IsAdmin {
shares, err = database.ListAllShares(h.DB)
} else {
shares, err = database.ListSharesByOwner(h.DB, user.ID)
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.page(w, r, "shares", "shares", "Shares", shares)
}
// handleShareCreateForAlbum creates a public share link for an entire
// album, reachable from the album page.
func (h *Web) handleShareCreateForAlbum(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
albumID := r.PathValue("id")
if _, err := database.GetAlbum(h.DB, albumID); err != nil {
http.NotFound(w, r)
return
}
share, err := database.CreateShare(h.DB, user.ID, idgen.New(""), "", sql.NullString{})
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := database.AddShareItem(h.DB, share.ID, sql.NullString{}, sql.NullString{String: albumID, Valid: true}, 0); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/shares", http.StatusFound)
}
func (h *Web) handleShareDelete(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
id := r.PathValue("id")
share, err := database.GetShare(h.DB, id)
if err != nil {
http.NotFound(w, r)
return
}
if share.OwnerID != user.ID && !user.IsAdmin {
http.Error(w, "403 Forbidden", http.StatusForbidden)
return
}
if err := database.DeleteShare(h.DB, id); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/shares", http.StatusFound)
}
+4
View File
@@ -10,6 +10,8 @@ import (
"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/jukebox"
"git.noroutes.net/NoRoutes/vibetunes/internal/podcasts"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
)
@@ -22,6 +24,8 @@ type Web struct {
Scanner *scanner.Scanner
CoverArt *coverart.Store
EncryptionKey []byte // for auth.SetUserPassword; see internal/auth/subsonic_secret.go
Podcasts *podcasts.Manager
Jukebox *jukebox.Jukebox
Log *slog.Logger
}