diff --git a/internal/app/app.go b/internal/app/app.go index c612b5c..93f450c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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() } diff --git a/internal/web/admin_handlers.go b/internal/web/admin_handlers.go index 17216c6..90486bf 100644 --- a/internal/web/admin_handlers.go +++ b/internal/web/admin_handlers.go @@ -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) diff --git a/internal/web/admin_jukebox_handlers.go b/internal/web/admin_jukebox_handlers.go new file mode 100644 index 0000000..6d6682e --- /dev/null +++ b/internal/web/admin_jukebox_handlers.go @@ -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) +} diff --git a/internal/web/admin_podcasts_handlers.go b/internal/web/admin_podcasts_handlers.go new file mode 100644 index 0000000..10386c8 --- /dev/null +++ b/internal/web/admin_podcasts_handlers.go @@ -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) +} diff --git a/internal/web/bookmarks_handlers.go b/internal/web/bookmarks_handlers.go new file mode 100644 index 0000000..26c48b9 --- /dev/null +++ b/internal/web/bookmarks_handlers.go @@ -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) +} diff --git a/internal/web/podcasts_handlers.go b/internal/web/podcasts_handlers.go new file mode 100644 index 0000000..219e82a --- /dev/null +++ b/internal/web/podcasts_handlers.go @@ -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) +} diff --git a/internal/web/radio_handlers.go b/internal/web/radio_handlers.go new file mode 100644 index 0000000..536f0f1 --- /dev/null +++ b/internal/web/radio_handlers.go @@ -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) +} diff --git a/internal/web/render.go b/internal/web/render.go index b823744..8365c13 100644 --- a/internal/web/render.go +++ b/internal/web/render.go @@ -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 diff --git a/internal/web/router.go b/internal/web/router.go index 4c00c58..df2a533 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -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) diff --git a/internal/web/shares_handlers.go b/internal/web/shares_handlers.go new file mode 100644 index 0000000..cf9f168 --- /dev/null +++ b/internal/web/shares_handlers.go @@ -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) +} diff --git a/internal/web/web.go b/internal/web/web.go index 6b5e829..08476fa 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -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 } diff --git a/web/templates/admin_jukebox.html b/web/templates/admin_jukebox.html new file mode 100644 index 0000000..e33b25f --- /dev/null +++ b/web/templates/admin_jukebox.html @@ -0,0 +1,41 @@ +{{define "admin_jukebox"}}{{template "header" .}} +{{if not .Data.Available}} +
mpv binary was found on this server. Install mpv and restart VibeSonic to enable server-side jukebox playback.
+{{.Data.Error}}
+ {{else}} +| Playing | {{.Data.Status.Playing}} |
| Current index | {{.Data.Status.CurrentIndex}} |
| Position | {{.Data.Status.PositionSecs}}s |
| Gain | {{.Data.Status.Gain}} |
| Playlist length | {{len .Data.Status.SongIDs}} |
+ Build a jukebox playlist from a Subsonic client (e.g. DSub, Symfonium) using the jukeboxControl API — this page is for server status and emergency stop/clear only.
+
| {{.Title}} | +{{.PublishDate}} | +{{.Status}} | ++ {{if ne .Status "completed"}} + + {{end}} + + | +
| Song | Position | Comment | |
|---|---|---|---|
| {{.Song.Title}} — {{.Song.ArtistName}} | +{{fmtDuration (millisToSec .Bookmark.PositionMs)}} | +{{.Bookmark.Comment}} | ++ + | +
No bookmarks yet.
+{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/partials/header.html b/web/templates/partials/header.html index 105a31a..0e6a745 100644 --- a/web/templates/partials/header.html +++ b/web/templates/partials/header.html @@ -69,6 +69,10 @@No podcasts yet. Ask an administrator to add a channel.
{{end}} +{{range .Data}} +{{.Channel.Description}}
+| {{.Title}} | +{{.PublishDate}} | +{{.Status}} | ++ {{if eq .Status "completed"}} Downloaded + {{else}} + + {{end}} + | +
No internet radio stations yet.
+{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/shares.html b/web/templates/shares.html new file mode 100644 index 0000000..04cd22f --- /dev/null +++ b/web/templates/shares.html @@ -0,0 +1,25 @@ +{{define "shares"}}{{template "header" .}} +{{if .Data}} +| Description | Owner | Visits | Link | |
|---|---|---|---|---|
| {{if .Description}}{{.Description}}{{else}}(no description){{end}} | +{{.OwnerID}} | +{{.VisitCount}} | +/share/{{.Token}} | ++ + | +
No shares yet. Create one from an album page.
+{{end}} +{{template "footer" .}}{{end}}