Web UI: podcasts, radio, shares, bookmarks pages; admin podcast/jukebox management; per-user Subsonic permission editor
This commit is contained in:
+10
-7
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user