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
+10 -7
View File
@@ -72,6 +72,12 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
return nil, fmt.Errorf("deriving auth encryption key: %w", err)
}
podcastMgr := podcasts.NewManager(db, cfg.DataDir, cfg.FFprobePath, log)
if err := podcastMgr.RecoverInterruptedDownloads(); err != nil {
log.Warn("podcast download recovery failed", "err", err)
}
jukeboxCtl := jukebox.New(cfg.MPVPath)
webApp := &web.Web{
DB: db,
Config: cfg,
@@ -81,14 +87,11 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
Scanner: sc,
CoverArt: covers,
EncryptionKey: encryptionKey,
Podcasts: podcastMgr,
Jukebox: jukeboxCtl,
Log: log,
}
podcastMgr := podcasts.NewManager(db, cfg.DataDir, cfg.FFprobePath, log)
if err := podcastMgr.RecoverInterruptedDownloads(); err != nil {
log.Warn("podcast download recovery failed", "err", err)
}
subsonicSrv := &subsonic.Server{
DB: db,
Config: cfg,
@@ -97,7 +100,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
CoverArt: covers,
Transcoder: transcoding.NewManager(cfg.FFmpegPath),
Podcasts: podcastMgr,
Jukebox: jukebox.New(cfg.MPVPath),
Jukebox: jukeboxCtl,
Log: log,
}
@@ -105,7 +108,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
}
func (a *App) Close() error {
a.Subsonic.Jukebox.Close()
a.Web.Jukebox.Close()
return a.DB.Close()
}
+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)
}
+1
View File
@@ -28,6 +28,7 @@ var funcMap = template.FuncMap{
},
"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
}
+41
View File
@@ -0,0 +1,41 @@
{{define "admin_jukebox"}}{{template "header" .}}
{{if not .Data.Available}}
<div class="alert alert-warning">
The jukebox is not available: no <code>mpv</code> binary was found on this server. Install mpv and restart VibeSonic to enable server-side jukebox playback.
</div>
{{else}}
<div class="card" style="max-width: 500px;">
<div class="card-body">
<h2 class="h6">Status</h2>
{{if .Data.Error}}
<p class="text-danger">{{.Data.Error}}</p>
{{else}}
<table class="table table-sm vs-table mb-3">
<tbody>
<tr><td>Playing</td><td>{{.Data.Status.Playing}}</td></tr>
<tr><td>Current index</td><td>{{.Data.Status.CurrentIndex}}</td></tr>
<tr><td>Position</td><td>{{.Data.Status.PositionSecs}}s</td></tr>
<tr><td>Gain</td><td>{{.Data.Status.Gain}}</td></tr>
<tr><td>Playlist length</td><td>{{len .Data.Status.SongIDs}}</td></tr>
</tbody>
</table>
{{end}}
<form method="post" action="/admin/jukebox/start" class="d-inline">
{{template "csrf_field" .CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary">Start</button>
</form>
<form method="post" action="/admin/jukebox/stop" class="d-inline">
{{template "csrf_field" .CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-secondary">Stop</button>
</form>
<form method="post" action="/admin/jukebox/clear" class="d-inline">
{{template "csrf_field" .CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger">Clear</button>
</form>
<p class="text-secondary small mt-3 mb-0">
Build a jukebox playlist from a Subsonic client (e.g. DSub, Symfonium) using the <code>jukeboxControl</code> API — this page is for server status and emergency stop/clear only.
</p>
</div>
</div>
{{end}}
{{template "footer" .}}{{end}}
+61
View File
@@ -0,0 +1,61 @@
{{define "admin_podcasts"}}{{template "header" .}}
<div class="card mb-4" style="max-width: 600px;">
<div class="card-body">
<h2 class="h6">Add channel</h2>
<form method="post" action="/admin/podcasts">
{{template "csrf_field" .CSRFToken}}
<div class="input-group input-group-sm">
<input type="text" name="url" class="form-control" placeholder="https://example.com/feed.xml" required>
<button type="submit" class="btn btn-primary">Add</button>
</div>
</form>
</div>
</div>
{{range .Data}}
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h2 class="h6 mb-0">{{.Channel.Title}} <span class="badge text-bg-secondary">{{.Channel.Status}}</span></h2>
<div class="text-secondary small">{{.Channel.URL}}</div>
{{if .Channel.ErrorMessage}}<div class="text-danger small">{{.Channel.ErrorMessage}}</div>{{end}}
</div>
<div>
<form method="post" action="/admin/podcasts/{{.Channel.ID}}/refresh" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary">Refresh</button>
</form>
<form method="post" action="/admin/podcasts/{{.Channel.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this channel and its episodes?');">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</div>
</div>
<table class="table table-sm vs-table mt-2 mb-0">
<tbody>
{{range .Episodes}}
<tr>
<td>{{.Title}}</td>
<td class="text-secondary small">{{.PublishDate}}</td>
<td>{{.Status}}</td>
<td class="text-end">
{{if ne .Status "completed"}}
<form method="post" action="/admin/podcasts/episodes/{{.ID}}/download" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary">Download</button>
</form>
{{end}}
<form method="post" action="/admin/podcasts/episodes/{{.ID}}/delete" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{template "footer" .}}{{end}}
+56
View File
@@ -0,0 +1,56 @@
{{define "admin_user_permissions"}}{{template "header" .}}
<div class="card" style="max-width: 500px;">
<div class="card-body">
<h2 class="h6">{{.Data.Username}}</h2>
<form method="post" action="/admin/users/{{.Data.ID}}/permissions">
{{template "csrf_field" .CSRFToken}}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="settings" value="1" id="p_settings" {{if .Data.Permissions.Settings}}checked{{end}}>
<label class="form-check-label" for="p_settings">Settings</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="stream" value="1" id="p_stream" {{if .Data.Permissions.Stream}}checked{{end}}>
<label class="form-check-label" for="p_stream">Stream</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="download" value="1" id="p_download" {{if .Data.Permissions.Download}}checked{{end}}>
<label class="form-check-label" for="p_download">Download</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="upload" value="1" id="p_upload" {{if .Data.Permissions.Upload}}checked{{end}}>
<label class="form-check-label" for="p_upload">Upload</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="playlist" value="1" id="p_playlist" {{if .Data.Permissions.Playlist}}checked{{end}}>
<label class="form-check-label" for="p_playlist">Playlist</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="cover_art" value="1" id="p_cover_art" {{if .Data.Permissions.CoverArt}}checked{{end}}>
<label class="form-check-label" for="p_cover_art">Cover Art</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="comment" value="1" id="p_comment" {{if .Data.Permissions.Comment}}checked{{end}}>
<label class="form-check-label" for="p_comment">Comment</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="podcast" value="1" id="p_podcast" {{if .Data.Permissions.Podcast}}checked{{end}}>
<label class="form-check-label" for="p_podcast">Podcast</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="share" value="1" id="p_share" {{if .Data.Permissions.Share}}checked{{end}}>
<label class="form-check-label" for="p_share">Share</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jukebox" value="1" id="p_jukebox" {{if .Data.Permissions.Jukebox}}checked{{end}}>
<label class="form-check-label" for="p_jukebox">Jukebox</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="video_conversion" value="1" id="p_video" {{if .Data.Permissions.VideoConversion}}checked{{end}}>
<label class="form-check-label" for="p_video">Video Conversion</label>
</div>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<a href="/admin/users" class="btn btn-sm btn-outline-secondary">Cancel</a>
</form>
</div>
</div>
{{template "footer" .}}{{end}}
+1
View File
@@ -27,6 +27,7 @@
<td>{{if .IsAdmin}}Admin{{else}}User{{end}}</td>
<td>{{if .Disabled}}<span class="text-danger">Disabled</span>{{else}}<span class="text-success">Active</span>{{end}}</td>
<td class="text-end">
<a href="/admin/users/{{.ID}}/permissions" class="btn btn-sm btn-outline-secondary">Permissions</a>
<form method="post" action="/admin/users/{{.ID}}/toggle-disabled" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-secondary">{{if .Disabled}}Enable{{else}}Disable{{end}}</button>
+4
View File
@@ -7,6 +7,10 @@
<button type="button" class="btn vs-star-btn {{if .Data.Starred}}active{{end}}" data-star-toggle data-item-type="album" data-item-id="{{.Data.Album.ID}}">
{{if .Data.Starred}}<i class="bi bi-star-fill"></i>{{else}}<i class="bi bi-star"></i>{{end}} Favorite
</button>
<form method="post" action="/albums/{{.Data.Album.ID}}/share" class="d-inline">
{{template "csrf_field" .CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-secondary"><i class="bi bi-share"></i> Share</button>
</form>
</div>
</div>
{{template "song_rows" .Data.Songs}}
+24
View File
@@ -0,0 +1,24 @@
{{define "bookmarks"}}{{template "header" .}}
{{if .Data}}
<table class="table vs-table table-sm">
<thead><tr><th>Song</th><th>Position</th><th>Comment</th><th></th></tr></thead>
<tbody>
{{range .Data}}
<tr>
<td>{{.Song.Title}} <span class="text-secondary small">— {{.Song.ArtistName}}</span></td>
<td>{{fmtDuration (millisToSec .Bookmark.PositionMs)}}</td>
<td class="text-secondary small">{{.Bookmark.Comment}}</td>
<td class="text-end">
<form method="post" action="/bookmarks/{{.Bookmark.SongID}}/delete">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger">Remove</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-secondary">No bookmarks yet.</p>
{{end}}
{{template "footer" .}}{{end}}
+6
View File
@@ -69,6 +69,10 @@
<li class="nav-item"><a class="nav-link {{if eq .Active "playlists"}}active{{end}}" href="/playlists"><i class="bi bi-list-ul"></i> Playlists</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "favorites"}}active{{end}}" href="/favorites"><i class="bi bi-star"></i> Favorites</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "recent"}}active{{end}}" href="/recent"><i class="bi bi-clock-history"></i> Recently Played</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "podcasts"}}active{{end}}" href="/podcasts"><i class="bi bi-broadcast"></i> Podcasts</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "radio"}}active{{end}}" href="/radio"><i class="bi bi-soundwave"></i> Radio</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "shares"}}active{{end}}" href="/shares"><i class="bi bi-share"></i> Shares</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "bookmarks"}}active{{end}}" href="/bookmarks"><i class="bi bi-bookmark"></i> Bookmarks</a></li>
{{if and .User .User.IsAdmin}}
<li class="vs-nav-label">Admin</li>
@@ -76,6 +80,8 @@
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_folders"}}active{{end}}" href="/admin/folders"><i class="bi bi-folder2-open"></i> Music Folders</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_scanner"}}active{{end}}" href="/admin/scanner"><i class="bi bi-arrow-repeat"></i> Scanner</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_settings"}}active{{end}}" href="/admin/settings"><i class="bi bi-sliders"></i> System Settings</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_podcasts"}}active{{end}}" href="/admin/podcasts"><i class="bi bi-broadcast"></i> Podcasts</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_jukebox"}}active{{end}}" href="/admin/jukebox"><i class="bi bi-cpu"></i> Jukebox</a></li>
{{end}}
</ul>
<div class="mt-auto px-3 py-2 text-secondary small">
+31
View File
@@ -0,0 +1,31 @@
{{define "podcasts"}}{{template "header" .}}
{{if not .Data}}<p class="text-secondary">No podcasts yet. Ask an administrator to add a channel.</p>{{end}}
{{range .Data}}
<div class="card mb-3">
<div class="card-body">
<h2 class="h6">{{.Channel.Title}}</h2>
<p class="text-secondary small">{{.Channel.Description}}</p>
<table class="table table-sm vs-table mb-0">
<tbody>
{{range .Episodes}}
<tr>
<td>{{.Title}}</td>
<td class="text-secondary small">{{.PublishDate}}</td>
<td>{{.Status}}</td>
<td class="text-end">
{{if eq .Status "completed"}}<span class="text-success"><i class="bi bi-check-circle"></i> Downloaded</span>
{{else}}
<form method="post" action="/podcasts/{{.ID}}/download">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary">Download</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{template "footer" .}}{{end}}
+42
View File
@@ -0,0 +1,42 @@
{{define "radio"}}{{template "header" .}}
{{if and .User .User.IsAdmin}}
<div class="card mb-4" style="max-width: 600px;">
<div class="card-body">
<h2 class="h6">Add station</h2>
<form method="post" action="/radio">
{{template "csrf_field" .CSRFToken}}
<div class="row g-2">
<div class="col-4"><input type="text" name="name" class="form-control form-control-sm" placeholder="Name" required></div>
<div class="col-4"><input type="text" name="stream_url" class="form-control form-control-sm" placeholder="Stream URL" required></div>
<div class="col-4"><input type="text" name="homepage_url" class="form-control form-control-sm" placeholder="Homepage (optional)"></div>
</div>
<button type="submit" class="btn btn-primary btn-sm mt-2">Add station</button>
</form>
</div>
</div>
{{end}}
{{if .Data}}
<div class="list-group">
{{range .Data}}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{.Name}}</div>
<div class="text-secondary small">{{.StreamURL}}</div>
</div>
<div>
<a class="btn btn-sm btn-outline-primary" href="{{.StreamURL}}"><i class="bi bi-play-fill"></i> Play</a>
{{if and $.User $.User.IsAdmin}}
<form method="post" action="/radio/{{.ID}}/delete" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
<p class="text-secondary">No internet radio stations yet.</p>
{{end}}
{{template "footer" .}}{{end}}
+25
View File
@@ -0,0 +1,25 @@
{{define "shares"}}{{template "header" .}}
{{if .Data}}
<table class="table vs-table table-sm">
<thead><tr><th>Description</th><th>Owner</th><th>Visits</th><th>Link</th><th></th></tr></thead>
<tbody>
{{range .Data}}
<tr>
<td>{{if .Description}}{{.Description}}{{else}}<span class="text-secondary">(no description)</span>{{end}}</td>
<td class="text-secondary small">{{.OwnerID}}</td>
<td>{{.VisitCount}}</td>
<td><a href="/share/{{.Token}}" target="_blank">/share/{{.Token}}</a></td>
<td class="text-end">
<form method="post" action="/shares/{{.ID}}/delete">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-secondary">No shares yet. Create one from an album page.</p>
{{end}}
{{template "footer" .}}{{end}}