Web UI: setup wizard, auth pages, library browsing, player, admin, streaming, cover art
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
// Command vibesonic is a self-contained, lightweight music/media server:
|
||||
// server-rendered Bootstrap 5 UI, SQLite storage, and (in later phases) a
|
||||
// Subsonic/OpenSubsonic-compatible API.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/app"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
|
||||
fmt.Println("vibesonic", version)
|
||||
return
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "vibesonic:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
a, err := app.Open(cfg, log)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "vibesonic:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer a.Close()
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if err := a.Run(ctx); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "vibesonic:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Package app wires together every subsystem (database, auth, scanner,
|
||||
// cover art, web UI) into a single running VibeSonic process.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"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/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/web"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
Cfg config.Config
|
||||
DB *sql.DB
|
||||
Log *slog.Logger
|
||||
Scanner *scanner.Scanner
|
||||
Web *web.Web
|
||||
}
|
||||
|
||||
// Open initializes the database and every subsystem, and imports
|
||||
// VIBESONIC_MUSIC_DIRS as music folders on first run. It does not start
|
||||
// any listeners or background loops.
|
||||
func Open(cfg config.Config, log *slog.Logger) (*App, error) {
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
db, err := database.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
if err := importConfiguredMusicDirs(db, cfg.MusicDirs); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("importing VIBESONIC_MUSIC_DIRS: %w", err)
|
||||
}
|
||||
|
||||
if cfg.SessionSecret == "" {
|
||||
log.Warn("VIBESONIC_SESSION_SECRET is not set; sessions are still database-backed and safe, but set this in production for defense in depth")
|
||||
}
|
||||
|
||||
prober := &metadata.Prober{FFprobePath: cfg.FFprobePath}
|
||||
sc := scanner.New(db, prober, log)
|
||||
|
||||
coverCache := filepath.Join(cfg.DataDir, "cache", "covers")
|
||||
covers := coverart.New(coverCache, cfg.FFmpegPath)
|
||||
|
||||
sessions := &auth.Sessions{DB: db, CookieSecure: cfg.CookieSecure}
|
||||
extauth := &auth.ExtAuth{DB: db, Config: cfg.ExtAuth}
|
||||
mw := &auth.Middleware{Sessions: sessions, ExtAuth: extauth}
|
||||
csrf := &auth.CSRF{CookieSecure: cfg.CookieSecure}
|
||||
|
||||
webApp := &web.Web{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
Sessions: sessions,
|
||||
CSRF: csrf,
|
||||
Middleware: mw,
|
||||
Scanner: sc,
|
||||
CoverArt: covers,
|
||||
Log: log,
|
||||
}
|
||||
|
||||
return &App{Cfg: cfg, DB: db, Log: log, Scanner: sc, Web: webApp}, nil
|
||||
}
|
||||
|
||||
func (a *App) Close() error {
|
||||
return a.DB.Close()
|
||||
}
|
||||
|
||||
// importConfiguredMusicDirs adds any VIBESONIC_MUSIC_DIRS entries not
|
||||
// already present as music folders, keyed by path so restarts don't
|
||||
// create duplicates.
|
||||
func importConfiguredMusicDirs(db *sql.DB, dirs []string) error {
|
||||
if len(dirs) == 0 {
|
||||
return nil
|
||||
}
|
||||
existing, err := database.ListMusicFolders(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byPath := make(map[string]bool, len(existing))
|
||||
for _, f := range existing {
|
||||
byPath[f.Path] = true
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if byPath[dir] {
|
||||
continue
|
||||
}
|
||||
name := filepath.Base(dir)
|
||||
if _, err := database.CreateMusicFolder(db, name, dir, "music"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run starts the HTTP server and the scheduled scanner loop, blocking
|
||||
// until ctx is cancelled, then shuts down gracefully.
|
||||
func (a *App) Run(ctx context.Context) error {
|
||||
go a.Scanner.RunScheduled(ctx, a.Cfg.ScanInterval)
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := database.DeleteExpiredSessions(a.DB); err != nil {
|
||||
a.Log.Warn("session cleanup failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: a.Cfg.ListenAddr,
|
||||
Handler: a.Web.NewRouter(),
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
a.Log.Info("listening", "addr", a.Cfg.ListenAddr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
}
|
||||
@@ -42,21 +42,23 @@ func scanCoverArt(row *sql.Row) (*CoverArt, error) {
|
||||
}
|
||||
|
||||
// UpsertAlbumCoverArt records (or replaces) the discovered cover art
|
||||
// source for an album and links it as the album's cover.
|
||||
func UpsertAlbumCoverArt(db *sql.DB, albumID, source, sourcePath, mimeType, contentHash string, width, height int) (*CoverArt, error) {
|
||||
// source for an album and links it as the album's cover. mediaFileID is
|
||||
// set (with sourcePath empty) for embedded artwork, which is extracted
|
||||
// on demand from the media file rather than at scan time.
|
||||
func UpsertAlbumCoverArt(db *sql.DB, albumID, source, sourcePath, mediaFileID, mimeType, contentHash string, width, height int) (*CoverArt, error) {
|
||||
var id string
|
||||
err := db.QueryRow(`SELECT id FROM cover_art WHERE album_id = ?`, albumID).Scan(&id)
|
||||
switch {
|
||||
case err == nil:
|
||||
_, err = db.Exec(`UPDATE cover_art SET source=?, source_path=?, mime_type=?, content_hash=?, width=?, height=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
|
||||
source, sourcePath, mimeType, contentHash, width, height, id)
|
||||
_, err = db.Exec(`UPDATE cover_art SET source=?, source_path=?, media_file_id=?, mime_type=?, content_hash=?, width=?, height=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
|
||||
source, sourcePath, nullEmptyStr(mediaFileID), mimeType, contentHash, width, height, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
id = idgen.New("ca")
|
||||
_, err = db.Exec(`INSERT INTO cover_art (id, album_id, source, source_path, mime_type, content_hash, width, height) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, albumID, source, sourcePath, mimeType, contentHash, width, height)
|
||||
_, err = db.Exec(`INSERT INTO cover_art (id, album_id, media_file_id, source, source_path, mime_type, content_hash, width, height) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, albumID, nullEmptyStr(mediaFileID), source, sourcePath, mimeType, contentHash, width, height)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -68,3 +70,10 @@ func UpsertAlbumCoverArt(db *sql.DB, albumID, source, sourcePath, mimeType, cont
|
||||
}
|
||||
return GetCoverArt(db, id)
|
||||
}
|
||||
|
||||
func nullEmptyStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -15,9 +15,11 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/library"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
|
||||
)
|
||||
|
||||
// recognizedExtensions are the file extensions VibeSonic attempts to
|
||||
@@ -168,9 +170,11 @@ func (s *Scanner) indexOne(ctx context.Context, folderID, absPath, relPath strin
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if _, err := library.IndexFile(s.DB, mf, tags); err != nil {
|
||||
song, err := library.IndexFile(s.DB, mf, tags)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
s.indexCoverArt(absPath, song, tags)
|
||||
return false, true, nil
|
||||
}
|
||||
if err != database.ErrNotFound {
|
||||
@@ -195,12 +199,36 @@ func (s *Scanner) indexOne(ctx context.Context, folderID, absPath, relPath strin
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if _, err := library.IndexFile(s.DB, mf, tags); err != nil {
|
||||
song, err := library.IndexFile(s.DB, mf, tags)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
s.indexCoverArt(absPath, song, tags)
|
||||
return true, false, nil
|
||||
}
|
||||
|
||||
// indexCoverArt records where an album's cover art can be found: a
|
||||
// folder image next to the file if one exists, otherwise the file's own
|
||||
// embedded artwork (extracted lazily by the web layer on request, not
|
||||
// here). Failures are non-fatal to the scan.
|
||||
func (s *Scanner) indexCoverArt(absPath string, song *database.Song, tags *metadata.Tags) {
|
||||
if !song.AlbumID.Valid {
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(absPath)
|
||||
if folderImage := coverart.FindFolderImage(dir); folderImage != "" {
|
||||
hash, err := coverart.ContentHashOf(folderImage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = database.UpsertAlbumCoverArt(s.DB, song.AlbumID.String, "folder", folderImage, "", streaming.MimeType(folderImage), hash, 0, 0)
|
||||
return
|
||||
}
|
||||
if tags.HasEmbeddedArt {
|
||||
_, _ = database.UpsertAlbumCoverArt(s.DB, song.AlbumID.String, "embedded", "", song.MediaFileID, "image/jpeg", song.MediaFileID, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// fingerprint is a cheap, fast rename/change detector based on size and
|
||||
// mtime rather than hashing file content (which would mean reading every
|
||||
// byte of every file on every scan).
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func (h *Web) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusFound)
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
func (h *Web) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := database.ListUsers(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "admin_users", "admin_users", "Users", users)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
isAdmin := r.FormValue("is_admin") == "1"
|
||||
if username == "" || len(password) < 8 {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusFound)
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := database.CreateUser(h.DB, username, "", hash, isAdmin); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/users", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminUserToggleDisabled(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
target, err := database.GetUserByID(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := database.SetUserDisabled(h.DB, id, !target.Disabled); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !target.Disabled { // just disabled it: revoke sessions
|
||||
_ = database.DeleteUserSessions(h.DB, id)
|
||||
}
|
||||
http.Redirect(w, r, "/admin/users", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminUserDelete(w http.ResponseWriter, r *http.Request) {
|
||||
current := auth.UserFromContext(r.Context())
|
||||
id := r.PathValue("id")
|
||||
if id == current.ID {
|
||||
http.Error(w, "cannot delete your own account", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := database.DeleteUser(h.DB, id); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/users", http.StatusFound)
|
||||
}
|
||||
|
||||
// --- Music folders ---
|
||||
|
||||
func (h *Web) handleAdminFolders(w http.ResponseWriter, r *http.Request) {
|
||||
folders, err := database.ListMusicFolders(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "admin_folders", "admin_folders", "Music Folders", folders)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminFolderCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
path := r.FormValue("path")
|
||||
kind := r.FormValue("kind")
|
||||
if kind == "" {
|
||||
kind = "music"
|
||||
}
|
||||
if name == "" || path == "" {
|
||||
http.Redirect(w, r, "/admin/folders", http.StatusFound)
|
||||
return
|
||||
}
|
||||
folder, err := database.CreateMusicFolder(h.DB, name, path, kind)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if h.Scanner != nil {
|
||||
go func() { _ = h.Scanner.ScanFolder(context.Background(), folder.ID) }()
|
||||
}
|
||||
http.Redirect(w, r, "/admin/folders", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminFolderToggle(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
folder, err := database.GetMusicFolder(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := database.SetMusicFolderEnabled(h.DB, id, !folder.Enabled); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/folders", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminFolderDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := database.DeleteMusicFolder(h.DB, id); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/folders", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminFolderScan(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := database.GetMusicFolder(h.DB, id); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if h.Scanner != nil {
|
||||
go func() { _ = h.Scanner.ScanFolder(context.Background(), id) }()
|
||||
}
|
||||
referer := r.Header.Get("Referer")
|
||||
if referer == "" {
|
||||
referer = "/admin/scanner"
|
||||
}
|
||||
http.Redirect(w, r, referer, http.StatusFound)
|
||||
}
|
||||
|
||||
// --- Scanner status ---
|
||||
|
||||
type folderScanStatus struct {
|
||||
Folder *database.MusicFolder
|
||||
State *database.ScanState
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminScanner(w http.ResponseWriter, r *http.Request) {
|
||||
folders, err := database.ListMusicFolders(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var out []folderScanStatus
|
||||
for _, f := range folders {
|
||||
state, err := database.GetScanState(h.DB, f.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, folderScanStatus{Folder: f, State: state})
|
||||
}
|
||||
h.page(w, r, "admin_scanner", "admin_scanner", "Scanner", out)
|
||||
}
|
||||
|
||||
// --- System settings ---
|
||||
|
||||
func (h *Web) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderAdminSettings(w, r, "", "")
|
||||
}
|
||||
|
||||
func (h *Web) renderAdminSettings(w http.ResponseWriter, r *http.Request, flash, kind string) {
|
||||
trusted := ""
|
||||
for i, p := range h.Config.ExtAuth.TrustedSources {
|
||||
if i > 0 {
|
||||
trusted += ", "
|
||||
}
|
||||
trusted += p.String()
|
||||
}
|
||||
h.flash(w, r, "admin_settings", "admin_settings", "System Settings", flash, kind, struct {
|
||||
ScanInterval string
|
||||
FFmpegPath string
|
||||
FFprobePath string
|
||||
ExtAuthEnabled bool
|
||||
ExtAuthTrustedSources string
|
||||
ExtAuthHeader string
|
||||
}{
|
||||
ScanInterval: database.GetSetting(h.DB, "scan_interval", h.Config.ScanInterval.String()),
|
||||
FFmpegPath: database.GetSetting(h.DB, "ffmpeg_path", h.Config.FFmpegPath),
|
||||
FFprobePath: database.GetSetting(h.DB, "ffprobe_path", h.Config.FFprobePath),
|
||||
ExtAuthEnabled: h.Config.ExtAuth.Enabled,
|
||||
ExtAuthTrustedSources: trusted,
|
||||
ExtAuthHeader: h.Config.ExtAuth.UserHeader,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Web) handleAdminSettingsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
interval := r.FormValue("scan_interval")
|
||||
if _, err := time.ParseDuration(interval); err != nil {
|
||||
h.renderAdminSettings(w, r, "Invalid scan interval: "+err.Error(), "danger")
|
||||
return
|
||||
}
|
||||
_ = database.SetSetting(h.DB, "scan_interval", interval)
|
||||
_ = database.SetSetting(h.DB, "ffmpeg_path", r.FormValue("ffmpeg_path"))
|
||||
_ = database.SetSetting(h.DB, "ffprobe_path", r.FormValue("ffprobe_path"))
|
||||
h.renderAdminSettings(w, r, "Settings saved. Some changes take effect after a restart.", "success")
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func (h *Web) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Sessions.CurrentUser(r) != nil {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
render(w, "login", PageData{
|
||||
Title: "Log in",
|
||||
CSRFToken: h.CSRF.Token(w, r),
|
||||
Data: struct{ Next string }{Next: r.URL.Query().Get("next")},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Web) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
next := r.FormValue("next")
|
||||
|
||||
user, err := database.GetUserByUsername(h.DB, username)
|
||||
if err != nil || user.Disabled || !user.PasswordHash.Valid {
|
||||
h.loginFailed(w, r, next)
|
||||
return
|
||||
}
|
||||
ok, err := auth.VerifyPassword(password, user.PasswordHash.String)
|
||||
if err != nil || !ok {
|
||||
h.loginFailed(w, r, next)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Sessions.Issue(w, r, user.ID); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = database.TouchLastLogin(h.DB, user.ID)
|
||||
|
||||
if next == "" || next[0] != '/' {
|
||||
next = "/"
|
||||
}
|
||||
http.Redirect(w, r, next, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) loginFailed(w http.ResponseWriter, r *http.Request, next string) {
|
||||
render(w, "login", PageData{
|
||||
Title: "Log in",
|
||||
CSRFToken: h.CSRF.Token(w, r),
|
||||
Flash: "Invalid username or password",
|
||||
FlashKind: "danger",
|
||||
Data: struct{ Next string }{Next: next},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Web) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
h.Sessions.Clear(w, r)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
// --- First-run setup ---
|
||||
|
||||
func (h *Web) handleSetupPage(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := database.CountUsers(h.DB)
|
||||
if err == nil && n > 0 {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
render(w, "setup", PageData{
|
||||
Title: "Setup",
|
||||
CSRFToken: h.CSRF.Token(w, r),
|
||||
Data: h.setupData(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Web) setupData() any {
|
||||
ffmpegPath := resolveOr(h.Config.FFmpegPath, "ffmpeg")
|
||||
ffprobePath := resolveOr(h.Config.FFprobePath, "ffprobe")
|
||||
_, ffmpegErr := exec.LookPath(ffmpegPath)
|
||||
_, ffprobeErr := exec.LookPath(ffprobePath)
|
||||
return struct {
|
||||
FFmpegPath string
|
||||
FFmpegFound bool
|
||||
FFprobePath string
|
||||
FFprobeFound bool
|
||||
}{
|
||||
FFmpegPath: ffmpegPath,
|
||||
FFmpegFound: ffmpegErr == nil,
|
||||
FFprobePath: ffprobePath,
|
||||
FFprobeFound: ffprobeErr == nil,
|
||||
}
|
||||
}
|
||||
|
||||
func resolveOr(configured, def string) string {
|
||||
if configured != "" {
|
||||
return configured
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (h *Web) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := database.CountUsers(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
folderName := r.FormValue("folder_name")
|
||||
folderPath := r.FormValue("folder_path")
|
||||
|
||||
if username == "" || len(password) < 8 || folderPath == "" {
|
||||
render(w, "setup", PageData{
|
||||
Title: "Setup", CSRFToken: h.CSRF.Token(w, r),
|
||||
Flash: "Please fill in all fields (password must be at least 8 characters)", FlashKind: "danger",
|
||||
Data: h.setupData(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user, err := database.CreateUser(h.DB, username, "", hash, true)
|
||||
if err != nil {
|
||||
render(w, "setup", PageData{
|
||||
Title: "Setup", CSRFToken: h.CSRF.Token(w, r),
|
||||
Flash: "Could not create the administrator account: " + err.Error(), FlashKind: "danger",
|
||||
Data: h.setupData(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
folder, err := database.CreateMusicFolder(h.DB, folderName, folderPath, "music")
|
||||
if err != nil {
|
||||
render(w, "setup", PageData{
|
||||
Title: "Setup", CSRFToken: h.CSRF.Token(w, r),
|
||||
Flash: "Administrator account created, but the music folder could not be added: " + err.Error(), FlashKind: "warning",
|
||||
Data: h.setupData(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Sessions.Issue(w, r, user.ID); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if h.Scanner != nil {
|
||||
go func() {
|
||||
_ = h.Scanner.ScanFolder(context.Background(), folder.ID)
|
||||
}()
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
|
||||
)
|
||||
|
||||
const maxCoverArtSize = 1200
|
||||
|
||||
// handleCoverArt serves a resized cover image, generating and caching it
|
||||
// on first request. Authorization: the caller must already be signed in
|
||||
// (enforced by the router's RequireUser wrapper on this route).
|
||||
func (h *Web) handleCoverArt(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
size := clampSize(r.URL.Query().Get("size"))
|
||||
|
||||
art, err := database.GetCoverArt(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
sourcePath, contentHash, isTemp, err := h.resolveCoverArtSource(r.Context(), art)
|
||||
if err != nil {
|
||||
http.Error(w, "cover art unavailable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if isTemp {
|
||||
defer os.Remove(sourcePath)
|
||||
}
|
||||
|
||||
cachedPath, err := h.CoverArt.GetOrCreate(sourcePath, contentHash, size)
|
||||
if err != nil {
|
||||
http.Error(w, "could not generate cover art", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||
if err := streaming.ServeFile(w, r, cachedPath, "cover.jpg"); err != nil {
|
||||
http.Error(w, "could not read cover art", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCoverArtSource returns a plain image file path for a cover_art
|
||||
// row, extracting embedded artwork to a temp file on demand. isTemp
|
||||
// tells the caller whether it must remove the returned path afterward.
|
||||
func (h *Web) resolveCoverArtSource(ctx context.Context, art *database.CoverArt) (path, contentHash string, isTemp bool, err error) {
|
||||
if art.Source == "folder" {
|
||||
return art.SourcePath, art.ContentHash, false, nil
|
||||
}
|
||||
|
||||
// Embedded: extract the attached-picture stream from the source
|
||||
// media file each time it's needed for resizing (the resize cache
|
||||
// below avoids repeating this on every request).
|
||||
mf, err := database.GetMediaFile(h.DB, art.MediaFileID.String)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
folder, err := database.GetMusicFolder(h.DB, mf.MusicFolderID)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
absPath, err := streaming.ResolvePath(folder.Path, mf.RelativePath)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
|
||||
prober := &metadata.Prober{FFprobePath: h.Config.FFprobePath}
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
tags, err := prober.Probe(ctx, absPath)
|
||||
if err != nil || !tags.HasEmbeddedArt {
|
||||
return "", "", false, err
|
||||
}
|
||||
|
||||
extracted, err := h.CoverArt.ExtractEmbedded(ctx, absPath, tags.EmbeddedArtStream)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
return extracted, art.ContentHash, true, nil
|
||||
}
|
||||
|
||||
func clampSize(raw string) int {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return 300
|
||||
}
|
||||
if n > maxCoverArtSize {
|
||||
return maxCoverArtSize
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package web
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (h *Web) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func (h *Web) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.DB.PingContext(r.Context()); err != nil {
|
||||
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
const pageSize = 60
|
||||
|
||||
func (h *Web) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
|
||||
albums, err := database.ListAlbums(h.DB, 12, 0)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var recentlyAdded []AlbumView
|
||||
for _, a := range albums {
|
||||
recentlyAdded = append(recentlyAdded, toAlbumView(h.DB, a))
|
||||
}
|
||||
|
||||
var recentlyPlayedAlbums []AlbumView
|
||||
if user != nil {
|
||||
history, err := database.ListRecentlyPlayed(h.DB, user.ID, 30)
|
||||
if err == nil {
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range history {
|
||||
song, err := database.GetSong(h.DB, entry.SongID)
|
||||
if err != nil || !song.AlbumID.Valid || seen[song.AlbumID.String] {
|
||||
continue
|
||||
}
|
||||
seen[song.AlbumID.String] = true
|
||||
album, err := database.GetAlbum(h.DB, song.AlbumID.String)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
recentlyPlayedAlbums = append(recentlyPlayedAlbums, toAlbumView(h.DB, album))
|
||||
if len(recentlyPlayedAlbums) >= 12 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasLibrary := len(recentlyAdded) > 0
|
||||
h.page(w, r, "home", "home", "Home", struct {
|
||||
HasLibrary bool
|
||||
RecentlyAdded []AlbumView
|
||||
RecentlyPlayedAlbums []AlbumView
|
||||
}{hasLibrary, recentlyAdded, recentlyPlayedAlbums})
|
||||
}
|
||||
|
||||
func (h *Web) handleArtists(w http.ResponseWriter, r *http.Request) {
|
||||
artists, err := database.ListArtists(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var views []ArtistView
|
||||
for _, a := range artists {
|
||||
views = append(views, toArtistView(a))
|
||||
}
|
||||
h.page(w, r, "artists", "artists", "Artists", views)
|
||||
}
|
||||
|
||||
func (h *Web) handleArtist(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
artist, err := database.GetArtist(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
albums, err := database.ListAlbumsByArtist(h.DB, id)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var views []AlbumView
|
||||
for _, a := range albums {
|
||||
views = append(views, toAlbumView(h.DB, a))
|
||||
}
|
||||
h.page(w, r, "artist", "artists", artist.Name, struct {
|
||||
Artist *database.Artist
|
||||
Albums []AlbumView
|
||||
}{artist, views})
|
||||
}
|
||||
|
||||
func (h *Web) handleAlbums(w http.ResponseWriter, r *http.Request) {
|
||||
offset := parseOffset(r)
|
||||
albums, err := database.ListAlbums(h.DB, pageSize+1, offset)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
hasMore := len(albums) > pageSize
|
||||
if hasMore {
|
||||
albums = albums[:pageSize]
|
||||
}
|
||||
var views []AlbumView
|
||||
for _, a := range albums {
|
||||
views = append(views, toAlbumView(h.DB, a))
|
||||
}
|
||||
h.page(w, r, "albums", "albums", "Albums", struct {
|
||||
Albums []AlbumView
|
||||
HasMore bool
|
||||
NextOffset int
|
||||
}{views, hasMore, offset + pageSize})
|
||||
}
|
||||
|
||||
func (h *Web) handleAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id := r.PathValue("id")
|
||||
album, err := database.GetAlbum(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
songs, err := database.ListSongsByAlbum(h.DB, id)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
userID := ""
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
}
|
||||
var rows []SongRow
|
||||
for _, s := range songs {
|
||||
rows = append(rows, toSongRow(h.DB, s, album, userID))
|
||||
}
|
||||
starred := false
|
||||
if user != nil {
|
||||
starred, _ = database.IsStarred(h.DB, user.ID, "album", album.ID)
|
||||
}
|
||||
h.page(w, r, "album", "albums", album.Title, struct {
|
||||
Album AlbumView
|
||||
Songs []SongRow
|
||||
Starred bool
|
||||
}{toAlbumView(h.DB, album), rows, starred})
|
||||
}
|
||||
|
||||
func (h *Web) handleSongs(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
offset := parseOffset(r)
|
||||
songs, err := database.ListSongs(h.DB, pageSize+1, offset)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
hasMore := len(songs) > pageSize
|
||||
if hasMore {
|
||||
songs = songs[:pageSize]
|
||||
}
|
||||
userID := ""
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
}
|
||||
var rows []SongRow
|
||||
for _, s := range songs {
|
||||
rows = append(rows, toSongRow(h.DB, s, albumOf(h.DB, s), userID))
|
||||
}
|
||||
h.page(w, r, "songs", "songs", "Songs", struct {
|
||||
Songs []SongRow
|
||||
HasMore bool
|
||||
NextOffset int
|
||||
}{rows, hasMore, offset + pageSize})
|
||||
}
|
||||
|
||||
func (h *Web) handleGenres(w http.ResponseWriter, r *http.Request) {
|
||||
genres, err := database.ListGenres(h.DB)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "genres", "genres", "Genres", genres)
|
||||
}
|
||||
|
||||
func (h *Web) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
|
||||
var artistViews []ArtistView
|
||||
var albumViews []AlbumView
|
||||
var songRows []SongRow
|
||||
|
||||
if query != "" {
|
||||
like := "%" + query + "%"
|
||||
|
||||
artistRows, _ := h.DB.Query(`SELECT DISTINCT a.id, a.name FROM artists a JOIN album_artists aa ON aa.artist_id = a.id WHERE a.name LIKE ? ORDER BY a.name LIMIT 20`, like)
|
||||
if artistRows != nil {
|
||||
defer artistRows.Close()
|
||||
for artistRows.Next() {
|
||||
var id, name string
|
||||
if artistRows.Scan(&id, &name) == nil {
|
||||
artistViews = append(artistViews, ArtistView{ID: id, Name: name})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
albumRows, _ := h.DB.Query(`SELECT id FROM albums WHERE title LIKE ? ORDER BY title LIMIT 20`, like)
|
||||
if albumRows != nil {
|
||||
defer albumRows.Close()
|
||||
for albumRows.Next() {
|
||||
var id string
|
||||
if albumRows.Scan(&id) == nil {
|
||||
if album, err := database.GetAlbum(h.DB, id); err == nil {
|
||||
albumViews = append(albumViews, toAlbumView(h.DB, album))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
songIDRows, _ := h.DB.Query(`SELECT id FROM songs WHERE title LIKE ? ORDER BY title LIMIT 40`, like)
|
||||
if songIDRows != nil {
|
||||
defer songIDRows.Close()
|
||||
userID := ""
|
||||
if user != nil {
|
||||
userID = user.ID
|
||||
}
|
||||
for songIDRows.Next() {
|
||||
var id string
|
||||
if songIDRows.Scan(&id) == nil {
|
||||
if song, err := database.GetSong(h.DB, id); err == nil {
|
||||
songRows = append(songRows, toSongRow(h.DB, song, albumOf(h.DB, song), userID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.page(w, r, "search", "search", "Search", struct {
|
||||
Query string
|
||||
Artists []ArtistView
|
||||
Albums []AlbumView
|
||||
Songs []SongRow
|
||||
}{query, artistViews, albumViews, songRows})
|
||||
}
|
||||
|
||||
func parseOffset(r *http.Request) int {
|
||||
n, err := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if err != nil || n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func (h *Web) handlePlaylists(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
playlists, err := database.ListPlaylistsVisibleToUser(h.DB, user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.page(w, r, "playlists", "playlists", "Playlists", playlists)
|
||||
}
|
||||
|
||||
func (h *Web) handlePlaylistCreate(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
http.Redirect(w, r, "/playlists", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if _, err := database.CreatePlaylist(h.DB, user.ID, name, "", false); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/playlists", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handlePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id := r.PathValue("id")
|
||||
playlist, err := database.GetPlaylist(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if playlist.OwnerID != user.ID && !playlist.IsPublic {
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
entries, err := database.ListPlaylistEntries(h.DB, id)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var rows []SongRow
|
||||
for _, e := range entries {
|
||||
song, err := database.GetSong(h.DB, e.SongID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
|
||||
}
|
||||
h.page(w, r, "playlist", "playlists", playlist.Name, struct {
|
||||
Playlist *database.Playlist
|
||||
Songs []SongRow
|
||||
Owned bool
|
||||
}{playlist, rows, playlist.OwnerID == user.ID})
|
||||
}
|
||||
|
||||
func (h *Web) handlePlaylistDelete(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id := r.PathValue("id")
|
||||
playlist, err := database.GetPlaylist(h.DB, id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if playlist.OwnerID != user.ID {
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := database.DeletePlaylist(h.DB, id); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/playlists", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handleFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
|
||||
artistIDs, _ := database.ListStarredIDs(h.DB, user.ID, "artist")
|
||||
var artists []ArtistView
|
||||
for _, id := range artistIDs {
|
||||
if a, err := database.GetArtist(h.DB, id); err == nil {
|
||||
artists = append(artists, toArtistView(a))
|
||||
}
|
||||
}
|
||||
|
||||
albumIDs, _ := database.ListStarredIDs(h.DB, user.ID, "album")
|
||||
var albums []AlbumView
|
||||
for _, id := range albumIDs {
|
||||
if a, err := database.GetAlbum(h.DB, id); err == nil {
|
||||
albums = append(albums, toAlbumView(h.DB, a))
|
||||
}
|
||||
}
|
||||
|
||||
songIDs, _ := database.ListStarredIDs(h.DB, user.ID, "song")
|
||||
var songs []SongRow
|
||||
for _, id := range songIDs {
|
||||
if s, err := database.GetSong(h.DB, id); err == nil {
|
||||
songs = append(songs, toSongRow(h.DB, s, albumOf(h.DB, s), user.ID))
|
||||
}
|
||||
}
|
||||
|
||||
h.page(w, r, "favorites", "favorites", "Favorites", struct {
|
||||
Artists []ArtistView
|
||||
Albums []AlbumView
|
||||
Songs []SongRow
|
||||
}{artists, albums, songs})
|
||||
}
|
||||
|
||||
func (h *Web) handleFavoriteStar(w http.ResponseWriter, r *http.Request) { h.setFavorite(w, r, true) }
|
||||
func (h *Web) handleFavoriteUnstar(w http.ResponseWriter, r *http.Request) {
|
||||
h.setFavorite(w, r, false)
|
||||
}
|
||||
|
||||
func (h *Web) setFavorite(w http.ResponseWriter, r *http.Request, star bool) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
itemType := r.FormValue("item_type")
|
||||
itemID := r.FormValue("item_id")
|
||||
next := r.FormValue("next")
|
||||
if next == "" || next[0] != '/' {
|
||||
next = "/"
|
||||
}
|
||||
switch itemType {
|
||||
case "song", "album", "artist":
|
||||
default:
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if star {
|
||||
err = database.Star(h.DB, user.ID, itemType, itemID)
|
||||
} else {
|
||||
err = database.Unstar(h.DB, user.ID, itemType, itemID)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, next, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handleRecent(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
history, err := database.ListRecentlyPlayed(h.DB, user.ID, 100)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var rows []SongRow
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range history {
|
||||
if seen[entry.SongID] {
|
||||
continue
|
||||
}
|
||||
seen[entry.SongID] = true
|
||||
song, err := database.GetSong(h.DB, entry.SongID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), user.ID))
|
||||
}
|
||||
h.page(w, r, "recent", "recent", "Recently Played", rows)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
assets "git.noroutes.net/NoRoutes/vibetunes/web"
|
||||
)
|
||||
|
||||
var funcMap = template.FuncMap{
|
||||
"fmtDuration": func(seconds float64) string {
|
||||
if seconds <= 0 {
|
||||
return "-"
|
||||
}
|
||||
d := time.Duration(seconds) * time.Second
|
||||
m := int(d.Minutes())
|
||||
s := int(d.Seconds()) % 60
|
||||
return fmt.Sprintf("%d:%02d", m, s)
|
||||
},
|
||||
"fmtTime": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return t.Format("2006-01-02 15:04")
|
||||
},
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
}
|
||||
|
||||
var templates *template.Template
|
||||
|
||||
func init() {
|
||||
t, err := template.New("").Funcs(funcMap).ParseFS(assets.FS, "templates/*.html", "templates/partials/*.html")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
templates = t
|
||||
}
|
||||
|
||||
// PageData is the common envelope passed to every page template.
|
||||
type PageData struct {
|
||||
Active string // nav item key for sidebar highlighting
|
||||
Title string
|
||||
User *database.User
|
||||
CSRFToken string
|
||||
Flash string
|
||||
FlashKind string // success|danger|warning
|
||||
Data any
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, name string, data PageData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := templates.ExecuteTemplate(w, name, data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
assets "git.noroutes.net/NoRoutes/vibetunes/web"
|
||||
)
|
||||
|
||||
// NewRouter builds VibeSonic's full HTTP handler: static assets, health
|
||||
// checks, first-run setup, authentication, and the authenticated
|
||||
// application (library browsing, playback, playlists, favorites,
|
||||
// settings, admin).
|
||||
func (h *Web) NewRouter() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.Handle("GET /static/", http.FileServer(http.FS(assets.FS)))
|
||||
mux.HandleFunc("GET /healthz", h.handleHealthz)
|
||||
mux.HandleFunc("GET /readyz", h.handleReadyz)
|
||||
|
||||
mux.HandleFunc("GET /setup", h.handleSetupPage)
|
||||
mux.HandleFunc("POST /setup", h.handleSetup)
|
||||
mux.HandleFunc("GET /login", h.handleLoginPage)
|
||||
mux.HandleFunc("POST /login", h.handleLogin)
|
||||
mux.HandleFunc("POST /logout", h.handleLogout)
|
||||
|
||||
mux.HandleFunc("GET /{$}", h.requireUser(h.handleHome))
|
||||
mux.HandleFunc("GET /search", h.requireUser(h.handleSearch))
|
||||
|
||||
mux.HandleFunc("GET /artists", h.requireUser(h.handleArtists))
|
||||
mux.HandleFunc("GET /artists/{id}", h.requireUser(h.handleArtist))
|
||||
mux.HandleFunc("GET /albums", h.requireUser(h.handleAlbums))
|
||||
mux.HandleFunc("GET /albums/{id}", h.requireUser(h.handleAlbum))
|
||||
mux.HandleFunc("GET /songs", h.requireUser(h.handleSongs))
|
||||
mux.HandleFunc("GET /genres", h.requireUser(h.handleGenres))
|
||||
|
||||
mux.HandleFunc("GET /playlists", h.requireUser(h.handlePlaylists))
|
||||
mux.HandleFunc("POST /playlists", h.requireUser(h.handlePlaylistCreate))
|
||||
mux.HandleFunc("GET /playlists/{id}", h.requireUser(h.handlePlaylist))
|
||||
mux.HandleFunc("POST /playlists/{id}/delete", h.requireUser(h.handlePlaylistDelete))
|
||||
|
||||
mux.HandleFunc("GET /favorites", h.requireUser(h.handleFavorites))
|
||||
mux.HandleFunc("POST /favorites/star", h.requireUser(h.handleFavoriteStar))
|
||||
mux.HandleFunc("POST /favorites/unstar", h.requireUser(h.handleFavoriteUnstar))
|
||||
mux.HandleFunc("GET /recent", h.requireUser(h.handleRecent))
|
||||
|
||||
mux.HandleFunc("GET /settings", h.requireUser(h.handleSettings))
|
||||
mux.HandleFunc("POST /settings/password", h.requireUser(h.handlePasswordChange))
|
||||
mux.HandleFunc("POST /settings/api-keys", h.requireUser(h.handleAPIKeyCreate))
|
||||
mux.HandleFunc("POST /settings/api-keys/{id}/revoke", h.requireUser(h.handleAPIKeyRevoke))
|
||||
mux.HandleFunc("POST /settings/sessions/{id}/revoke", h.requireUser(h.handleSessionRevoke))
|
||||
|
||||
mux.HandleFunc("GET /stream/{id}", h.requireUser(h.handleStream))
|
||||
mux.HandleFunc("GET /cover-art/{id}", h.requireUser(h.handleCoverArt))
|
||||
|
||||
mux.HandleFunc("GET /admin", h.requireAdmin(h.handleAdminRoot))
|
||||
mux.HandleFunc("GET /admin/users", h.requireAdmin(h.handleAdminUsers))
|
||||
mux.HandleFunc("POST /admin/users", h.requireAdmin(h.handleAdminUserCreate))
|
||||
mux.HandleFunc("POST /admin/users/{id}/toggle-disabled", h.requireAdmin(h.handleAdminUserToggleDisabled))
|
||||
mux.HandleFunc("POST /admin/users/{id}/delete", h.requireAdmin(h.handleAdminUserDelete))
|
||||
|
||||
mux.HandleFunc("GET /admin/folders", h.requireAdmin(h.handleAdminFolders))
|
||||
mux.HandleFunc("POST /admin/folders", h.requireAdmin(h.handleAdminFolderCreate))
|
||||
mux.HandleFunc("POST /admin/folders/{id}/toggle", h.requireAdmin(h.handleAdminFolderToggle))
|
||||
mux.HandleFunc("POST /admin/folders/{id}/delete", h.requireAdmin(h.handleAdminFolderDelete))
|
||||
mux.HandleFunc("POST /admin/folders/{id}/scan", h.requireAdmin(h.handleAdminFolderScan))
|
||||
|
||||
mux.HandleFunc("GET /admin/scanner", h.requireAdmin(h.handleAdminScanner))
|
||||
mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings))
|
||||
mux.HandleFunc("POST /admin/settings", h.requireAdmin(h.handleAdminSettingsUpdate))
|
||||
|
||||
var handler http.Handler = mux
|
||||
handler = h.CSRF.Protect(handler)
|
||||
handler = h.setupGate(handler)
|
||||
handler = h.Middleware.Authenticate(handler)
|
||||
return handler
|
||||
}
|
||||
|
||||
func (h *Web) requireUser(fn http.HandlerFunc) http.HandlerFunc {
|
||||
wrapped := h.Middleware.RequireUser(fn)
|
||||
return wrapped.ServeHTTP
|
||||
}
|
||||
|
||||
func (h *Web) requireAdmin(fn http.HandlerFunc) http.HandlerFunc {
|
||||
wrapped := h.Middleware.RequireAdmin(fn)
|
||||
return wrapped.ServeHTTP
|
||||
}
|
||||
|
||||
// setupGate sends every request to /setup until an administrator
|
||||
// account exists, so a fresh install can never be used half-configured.
|
||||
func (h *Web) setupGate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/setup" || r.URL.Path == "/healthz" || r.URL.Path == "/readyz" ||
|
||||
len(r.URL.Path) >= 8 && r.URL.Path[:8] == "/static/" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
n, err := database.CountUsers(h.DB)
|
||||
if err == nil && n == 0 {
|
||||
http.Redirect(w, r, "/setup", http.StatusFound)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
func (h *Web) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderSettings(w, r, "", "", "")
|
||||
}
|
||||
|
||||
func (h *Web) renderSettings(w http.ResponseWriter, r *http.Request, newAPIKey, flash, flashKind string) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
keys, err := database.ListAPIKeysForUser(h.DB, user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sessions, err := database.ListUserSessions(h.DB, user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.flash(w, r, "settings", "", "Settings", flash, flashKind, struct {
|
||||
APIKeys []*database.APIKey
|
||||
Sessions []*database.Session
|
||||
NewAPIKey string
|
||||
}{keys, sessions, newAPIKey})
|
||||
}
|
||||
|
||||
func (h *Web) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
current := r.FormValue("current_password")
|
||||
newPassword := r.FormValue("new_password")
|
||||
|
||||
if !user.PasswordHash.Valid {
|
||||
h.renderSettings(w, r, "", "This account has no local password to change (it signs in via external authentication).", "danger")
|
||||
return
|
||||
}
|
||||
ok, err := auth.VerifyPassword(current, user.PasswordHash.String)
|
||||
if err != nil || !ok {
|
||||
h.renderSettings(w, r, "", "Current password is incorrect.", "danger")
|
||||
return
|
||||
}
|
||||
if len(newPassword) < 8 {
|
||||
h.renderSettings(w, r, "", "New password must be at least 8 characters.", "danger")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := database.SetUserPassword(h.DB, user.ID, hash); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = database.DeleteUserSessions(h.DB, user.ID)
|
||||
if err := h.Sessions.Issue(w, r, user.ID); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderSettings(w, r, "", "Password updated.", "success")
|
||||
}
|
||||
|
||||
func (h *Web) handleAPIKeyCreate(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
rawKey, err := auth.GenerateAPIKey()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := database.CreateAPIKey(h.DB, user.ID, name, auth.HashAPIKey(rawKey)); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderSettings(w, r, rawKey, "", "")
|
||||
}
|
||||
|
||||
func (h *Web) handleAPIKeyRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id := r.PathValue("id")
|
||||
key, err := database.GetAPIKey(h.DB, id)
|
||||
if err != nil || key.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := database.RevokeAPIKey(h.DB, id); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Web) handleSessionRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
id := r.PathValue("id")
|
||||
sess, err := database.GetSession(h.DB, id)
|
||||
if err != nil || sess.UserID != user.ID {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := database.DeleteSession(h.DB, id); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
|
||||
)
|
||||
|
||||
// handleStream serves a song's audio bytes directly (no transcoding in
|
||||
// Phase 1). Authorization happens here, before any file is opened: the
|
||||
// caller must be a signed-in, non-disabled user (enforced by the
|
||||
// RequireUser middleware wrapping this route), and the resolved path is
|
||||
// always re-validated against its music folder root.
|
||||
func (h *Web) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
songID := r.PathValue("id")
|
||||
|
||||
song, err := database.GetSong(h.DB, songID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
mediaFile, err := database.GetMediaFile(h.DB, song.MediaFileID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
folder, err := database.GetMusicFolder(h.DB, mediaFile.MusicFolderID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
absPath, err := streaming.ResolvePath(folder.Path, mediaFile.RelativePath)
|
||||
if err != nil {
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Best-effort playback tracking: only record once per playback (on
|
||||
// the request for the start of the file, not every range chunk a
|
||||
// browser issues while seeking/buffering).
|
||||
rangeHeader := r.Header.Get("Range")
|
||||
if rangeHeader == "" || strings.HasPrefix(rangeHeader, "bytes=0-") {
|
||||
_ = database.SetNowPlaying(h.DB, user.ID, song.ID, "web", 0)
|
||||
_ = database.RecordPlayback(h.DB, user.ID, song.ID, 0)
|
||||
}
|
||||
|
||||
if err := streaming.ServeFile(w, r, absPath, mediaFile.RelativePath); err != nil {
|
||||
http.Error(w, "could not read media file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
// View-model structs shape database rows for templates, keeping SQL
|
||||
// scanning details (sql.Null*) out of the .html files.
|
||||
|
||||
type ArtistView struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type AlbumView struct {
|
||||
ID string
|
||||
Title string
|
||||
ArtistName string
|
||||
Year int64
|
||||
CoverArtID string
|
||||
}
|
||||
|
||||
type SongRow struct {
|
||||
ID string
|
||||
Title string
|
||||
ArtistName string
|
||||
AlbumTitle string
|
||||
AlbumID string
|
||||
CoverArtID string
|
||||
TrackNumber int64
|
||||
DurationSeconds float64
|
||||
Starred bool
|
||||
}
|
||||
|
||||
func toArtistView(a *database.Artist) ArtistView {
|
||||
return ArtistView{ID: a.ID, Name: a.Name}
|
||||
}
|
||||
|
||||
func toAlbumView(db *sql.DB, a *database.Album) AlbumView {
|
||||
v := AlbumView{ID: a.ID, Title: a.Title}
|
||||
if a.Year.Valid {
|
||||
v.Year = a.Year.Int64
|
||||
}
|
||||
if a.CoverArtID.Valid {
|
||||
v.CoverArtID = a.CoverArtID.String
|
||||
}
|
||||
if a.PrimaryAlbumArtistID.Valid {
|
||||
if artist, err := database.GetArtist(db, a.PrimaryAlbumArtistID.String); err == nil {
|
||||
v.ArtistName = artist.Name
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// toSongRow builds a display row for a song, given its owning album (may
|
||||
// be nil for albumless tracks) and the current user (for the starred
|
||||
// flag; pass "" userID to skip that lookup).
|
||||
func toSongRow(db *sql.DB, s *database.Song, album *database.Album, userID string) SongRow {
|
||||
row := SongRow{
|
||||
ID: s.ID,
|
||||
Title: s.Title,
|
||||
DurationSeconds: 0,
|
||||
}
|
||||
if s.TrackNumber.Valid {
|
||||
row.TrackNumber = s.TrackNumber.Int64
|
||||
}
|
||||
if names, err := database.ListSongArtistNames(db, s.ID); err == nil && len(names) > 0 {
|
||||
row.ArtistName = names[0]
|
||||
for _, n := range names[1:] {
|
||||
row.ArtistName += ", " + n
|
||||
}
|
||||
}
|
||||
if album != nil {
|
||||
row.AlbumTitle = album.Title
|
||||
row.AlbumID = album.ID
|
||||
if album.CoverArtID.Valid {
|
||||
row.CoverArtID = album.CoverArtID.String
|
||||
}
|
||||
}
|
||||
if mf, err := database.GetMediaFile(db, s.MediaFileID); err == nil {
|
||||
row.DurationSeconds = mf.DurationSeconds
|
||||
}
|
||||
if userID != "" {
|
||||
if starred, err := database.IsStarred(db, userID, "song", s.ID); err == nil {
|
||||
row.Starred = starred
|
||||
}
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// albumOf loads a song's album, tolerating albumless songs.
|
||||
func albumOf(db *sql.DB, s *database.Song) *database.Album {
|
||||
if !s.AlbumID.Valid {
|
||||
return nil
|
||||
}
|
||||
album, err := database.GetAlbum(db, s.AlbumID.String)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return album
|
||||
}
|
||||
|
||||
type SessionView struct {
|
||||
ID string
|
||||
UserAgent string
|
||||
IPAddress string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Package web implements VibeSonic's server-rendered Bootstrap 5 UI:
|
||||
// setup, authentication, library browsing, playback, and admin pages.
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/auth"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
|
||||
)
|
||||
|
||||
type Web struct {
|
||||
DB *sql.DB
|
||||
Config config.Config
|
||||
Sessions *auth.Sessions
|
||||
CSRF *auth.CSRF
|
||||
Middleware *auth.Middleware
|
||||
Scanner *scanner.Scanner
|
||||
CoverArt *coverart.Store
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// page renders a template with the standard envelope (current user, CSRF
|
||||
// token, active nav item) filled in.
|
||||
func (h *Web) page(w http.ResponseWriter, r *http.Request, name, active, title string, data any) {
|
||||
render(w, name, PageData{
|
||||
Active: active,
|
||||
Title: title,
|
||||
User: auth.UserFromContext(r.Context()),
|
||||
CSRFToken: h.CSRF.Token(w, r),
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Web) flash(w http.ResponseWriter, r *http.Request, name, active, title, flash, kind string, data any) {
|
||||
render(w, name, PageData{
|
||||
Active: active,
|
||||
Title: title,
|
||||
User: auth.UserFromContext(r.Context()),
|
||||
CSRFToken: h.CSRF.Token(w, r),
|
||||
Flash: flash,
|
||||
FlashKind: kind,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Package assets embeds VibeSonic's server-rendered templates and static
|
||||
// files (Bootstrap 5, icons, the player's JS) into the binary.
|
||||
package assets
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates static
|
||||
var FS embed.FS
|
||||
@@ -0,0 +1,312 @@
|
||||
/* VibeSonic — compact, dark-first UI on top of Bootstrap 5. */
|
||||
|
||||
:root {
|
||||
--vs-sidebar-width: 230px;
|
||||
--vs-player-height: 76px;
|
||||
--vs-topbar-height: 52px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* --- Top bar --- */
|
||||
|
||||
.vs-topbar {
|
||||
height: var(--vs-topbar-height);
|
||||
padding: 0 1rem;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.vs-brand {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-decoration: none;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.vs-brand i {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.vs-icon-btn {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
background: transparent;
|
||||
color: var(--bs-body-color);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.vs-icon-btn:hover {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
/* --- Shell --- */
|
||||
|
||||
.vs-shell {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vs-sidebar {
|
||||
width: var(--vs-sidebar-width);
|
||||
flex: 0 0 auto;
|
||||
border-right: 1px solid var(--bs-border-color);
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.vs-nav .nav-link {
|
||||
color: var(--bs-secondary-color);
|
||||
padding: 0.35rem 1rem;
|
||||
border-radius: 0;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.vs-nav .nav-link i {
|
||||
width: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vs-nav .nav-link:hover {
|
||||
background: var(--bs-secondary-bg);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.vs-nav .nav-link.active {
|
||||
background: var(--bs-secondary-bg);
|
||||
color: var(--bs-body-color);
|
||||
border-left: 2px solid var(--bs-primary);
|
||||
padding-left: calc(1rem - 2px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vs-nav-label {
|
||||
padding: 0.75rem 1rem 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--bs-tertiary-color);
|
||||
}
|
||||
|
||||
.vs-main {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding-bottom: calc(var(--vs-player-height) + 1rem);
|
||||
}
|
||||
|
||||
.vs-page-header {
|
||||
padding: 1rem 1.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.vs-content {
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
/* --- Album/artist grid --- */
|
||||
|
||||
.vs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.vs-card {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.vs-card-art {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 100%;
|
||||
border-radius: 0.375rem;
|
||||
background: var(--bs-secondary-bg);
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vs-card-title {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vs-card-subtitle {
|
||||
font-size: 0.78rem;
|
||||
color: var(--bs-secondary-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Compact tables --- */
|
||||
|
||||
.vs-table td, .vs-table th {
|
||||
padding: 0.35rem 0.6rem;
|
||||
vertical-align: middle;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.vs-table-hover tbody tr:hover {
|
||||
background: var(--bs-secondary-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vs-track-num {
|
||||
color: var(--bs-tertiary-color);
|
||||
width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* --- Player bar --- */
|
||||
|
||||
.vs-player {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: var(--vs-player-height);
|
||||
background: var(--bs-body-bg);
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
z-index: 1030;
|
||||
}
|
||||
|
||||
.vs-player-art {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--bs-secondary-bg);
|
||||
object-fit: cover;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.vs-player-meta {
|
||||
min-width: 140px;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vs-player-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.vs-player-artist {
|
||||
font-size: 0.78rem;
|
||||
color: var(--bs-secondary-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.vs-player-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vs-player-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.vs-player-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--bs-body-color);
|
||||
font-size: 1.1rem;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.vs-player-btn:hover {
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
.vs-player-btn.vs-play {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.vs-player-timeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vs-player-time {
|
||||
font-size: 0.72rem;
|
||||
color: var(--bs-secondary-color);
|
||||
width: 2.6rem;
|
||||
text-align: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.vs-player-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 130px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* --- Misc --- */
|
||||
|
||||
.vs-star-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--bs-tertiary-color);
|
||||
}
|
||||
|
||||
.vs-star-btn.active,
|
||||
.vs-star-btn:hover {
|
||||
color: #f0b429;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.vs-sidebar {
|
||||
display: none;
|
||||
}
|
||||
.vs-player-meta {
|
||||
max-width: 100px;
|
||||
}
|
||||
.vs-player-volume {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
|
||||
<rect width="200" height="200" fill="#2b2f36"/>
|
||||
<circle cx="100" cy="100" r="46" fill="#3d434d"/>
|
||||
<circle cx="100" cy="100" r="10" fill="#2b2f36"/>
|
||||
<path d="M100 54a46 46 0 0 1 46 46h-10a36 36 0 0 0 -36 -36z" fill="#565e6b"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 329 B |
@@ -0,0 +1,299 @@
|
||||
/* VibeSonic — theme toggle + bottom player. Deliberately minimal
|
||||
* vanilla JS: no bundler, no framework. */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// --- Theme toggle ---
|
||||
function initTheme() {
|
||||
var toggle = document.querySelector("[data-theme-toggle]");
|
||||
if (!toggle) return;
|
||||
toggle.addEventListener("click", function () {
|
||||
var root = document.documentElement;
|
||||
var current = root.getAttribute("data-bs-theme") || "dark";
|
||||
var next = current === "dark" ? "light" : "dark";
|
||||
root.setAttribute("data-bs-theme", next);
|
||||
try {
|
||||
localStorage.setItem("vs-theme", next);
|
||||
} catch (e) {
|
||||
/* storage may be unavailable (private browsing); theme just won't persist */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Player ---
|
||||
function VibeSonicPlayer(audioEl, ui) {
|
||||
this.audio = audioEl;
|
||||
this.ui = ui;
|
||||
this.queue = [];
|
||||
this.index = -1;
|
||||
this.audio.addEventListener("timeupdate", this._onTimeUpdate.bind(this));
|
||||
this.audio.addEventListener("loadedmetadata", this._onTimeUpdate.bind(this));
|
||||
this.audio.addEventListener("ended", this.next.bind(this));
|
||||
this.audio.addEventListener("play", this._syncPlayIcon.bind(this));
|
||||
this.audio.addEventListener("pause", this._syncPlayIcon.bind(this));
|
||||
}
|
||||
|
||||
VibeSonicPlayer.prototype.playQueue = function (songs, startIndex) {
|
||||
this.queue = songs;
|
||||
this.index = startIndex || 0;
|
||||
this._loadCurrent();
|
||||
this.audio.play().catch(function () {});
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype._loadCurrent = function () {
|
||||
var song = this.queue[this.index];
|
||||
if (!song) return;
|
||||
this.audio.src = "/stream/" + encodeURIComponent(song.id);
|
||||
this.ui.title.textContent = song.title || "";
|
||||
this.ui.artist.textContent = song.artist || "";
|
||||
this.ui.art.src = song.cover ? "/cover-art/" + encodeURIComponent(song.cover) + "?size=100" : "/static/img/default-art.svg";
|
||||
this.ui.starBtn.dataset.itemType = "song";
|
||||
this.ui.starBtn.dataset.itemId = song.id;
|
||||
this.ui.starBtn.classList.toggle("active", !!song.starred);
|
||||
this.ui.starBtn.innerHTML = song.starred ? '<i class="bi bi-star-fill"></i>' : '<i class="bi bi-star"></i>';
|
||||
|
||||
if ("mediaSession" in navigator) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: song.title || "",
|
||||
artist: song.artist || "",
|
||||
album: song.album || "",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype.playPause = function () {
|
||||
if (!this.queue.length) return;
|
||||
if (this.audio.paused) {
|
||||
this.audio.play().catch(function () {});
|
||||
} else {
|
||||
this.audio.pause();
|
||||
}
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype.next = function () {
|
||||
if (this.index + 1 < this.queue.length) {
|
||||
this.index++;
|
||||
this._loadCurrent();
|
||||
this.audio.play().catch(function () {});
|
||||
}
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype.prev = function () {
|
||||
if (this.audio.currentTime > 3) {
|
||||
this.audio.currentTime = 0;
|
||||
return;
|
||||
}
|
||||
if (this.index > 0) {
|
||||
this.index--;
|
||||
this._loadCurrent();
|
||||
this.audio.play().catch(function () {});
|
||||
}
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype.seekTo = function (fraction) {
|
||||
if (this.audio.duration) {
|
||||
this.audio.currentTime = fraction * this.audio.duration;
|
||||
}
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype.seekBy = function (deltaSeconds) {
|
||||
this.audio.currentTime = Math.max(0, this.audio.currentTime + deltaSeconds);
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype.setVolume = function (v) {
|
||||
this.audio.volume = Math.min(1, Math.max(0, v));
|
||||
this.ui.volumeSlider.value = this.audio.volume;
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype.toggleMute = function () {
|
||||
this.audio.muted = !this.audio.muted;
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype._syncPlayIcon = function () {
|
||||
this.ui.playBtn.innerHTML = this.audio.paused
|
||||
? '<i class="bi bi-play-fill"></i>'
|
||||
: '<i class="bi bi-pause-fill"></i>';
|
||||
};
|
||||
|
||||
VibeSonicPlayer.prototype._onTimeUpdate = function () {
|
||||
var cur = this.audio.currentTime || 0;
|
||||
var dur = this.audio.duration || 0;
|
||||
this.ui.currentTime.textContent = formatTime(cur);
|
||||
this.ui.duration.textContent = formatTime(dur);
|
||||
if (dur > 0) {
|
||||
this.ui.progress.value = String((cur / dur) * 100);
|
||||
}
|
||||
};
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (!isFinite(seconds) || seconds < 0) return "0:00";
|
||||
var m = Math.floor(seconds / 60);
|
||||
var s = Math.floor(seconds % 60);
|
||||
return m + ":" + (s < 10 ? "0" : "") + s;
|
||||
}
|
||||
|
||||
function collectQueueFrom(el) {
|
||||
var container = el.closest("[data-playable-list]") || document;
|
||||
var nodes = container.querySelectorAll(".vs-playable");
|
||||
var songs = [];
|
||||
var startIndex = 0;
|
||||
nodes.forEach(function (node, i) {
|
||||
if (node === el || node.contains(el)) startIndex = i;
|
||||
songs.push({
|
||||
id: node.dataset.songId,
|
||||
title: node.dataset.title,
|
||||
artist: node.dataset.artist,
|
||||
album: node.dataset.album,
|
||||
cover: node.dataset.cover,
|
||||
starred: node.dataset.starred === "1",
|
||||
});
|
||||
});
|
||||
return { songs: songs, startIndex: startIndex };
|
||||
}
|
||||
|
||||
function initPlayer() {
|
||||
var audio = document.getElementById("vs-audio");
|
||||
if (!audio) return null;
|
||||
|
||||
var ui = {
|
||||
art: document.getElementById("vs-player-art"),
|
||||
title: document.getElementById("vs-player-title"),
|
||||
artist: document.getElementById("vs-player-artist"),
|
||||
playBtn: document.getElementById("vs-play-btn"),
|
||||
currentTime: document.getElementById("vs-current-time"),
|
||||
duration: document.getElementById("vs-duration"),
|
||||
progress: document.getElementById("vs-progress"),
|
||||
volumeSlider: document.getElementById("vs-volume"),
|
||||
starBtn: document.getElementById("vs-now-star"),
|
||||
};
|
||||
var player = new VibeSonicPlayer(audio, ui);
|
||||
|
||||
document.getElementById("vs-play-btn").addEventListener("click", function () {
|
||||
player.playPause();
|
||||
});
|
||||
document.getElementById("vs-next-btn").addEventListener("click", function () {
|
||||
player.next();
|
||||
});
|
||||
document.getElementById("vs-prev-btn").addEventListener("click", function () {
|
||||
player.prev();
|
||||
});
|
||||
ui.progress.addEventListener("input", function () {
|
||||
player.seekTo(Number(ui.progress.value) / 100);
|
||||
});
|
||||
ui.volumeSlider.addEventListener("input", function () {
|
||||
player.setVolume(Number(ui.volumeSlider.value));
|
||||
});
|
||||
document.getElementById("vs-mute-btn").addEventListener("click", function () {
|
||||
player.toggleMute();
|
||||
});
|
||||
|
||||
document.addEventListener("click", function (ev) {
|
||||
var el = ev.target.closest(".vs-playable");
|
||||
if (!el) return;
|
||||
ev.preventDefault();
|
||||
var q = collectQueueFrom(el);
|
||||
player.playQueue(q.songs, q.startIndex);
|
||||
});
|
||||
|
||||
if ("mediaSession" in navigator) {
|
||||
navigator.mediaSession.setActionHandler("play", function () { player.playPause(); });
|
||||
navigator.mediaSession.setActionHandler("pause", function () { player.playPause(); });
|
||||
navigator.mediaSession.setActionHandler("previoustrack", function () { player.prev(); });
|
||||
navigator.mediaSession.setActionHandler("nexttrack", function () { player.next(); });
|
||||
}
|
||||
|
||||
player.setVolume(1);
|
||||
return player;
|
||||
}
|
||||
|
||||
function isTypingTarget(el) {
|
||||
if (!el) return false;
|
||||
var tag = el.tagName;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
||||
}
|
||||
|
||||
function initKeyboardShortcuts(player) {
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (isTypingTarget(document.activeElement)) return;
|
||||
if (ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
||||
|
||||
switch (ev.key) {
|
||||
case " ":
|
||||
case "k":
|
||||
case "K":
|
||||
ev.preventDefault();
|
||||
if (player) player.playPause();
|
||||
break;
|
||||
case "j":
|
||||
case "J":
|
||||
if (player) player.prev();
|
||||
break;
|
||||
case "l":
|
||||
case "L":
|
||||
if (player) player.next();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (player) player.seekBy(-5);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (player) player.seekBy(5);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
ev.preventDefault();
|
||||
if (player) player.setVolume(player.audio.volume + 0.1);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
ev.preventDefault();
|
||||
if (player) player.setVolume(player.audio.volume - 0.1);
|
||||
break;
|
||||
case "m":
|
||||
case "M":
|
||||
if (player) player.toggleMute();
|
||||
break;
|
||||
case "/":
|
||||
var search = document.getElementById("vs-search-input");
|
||||
if (search) {
|
||||
ev.preventDefault();
|
||||
search.focus();
|
||||
}
|
||||
break;
|
||||
case "?":
|
||||
var helpModalEl = document.getElementById("vs-shortcuts-modal");
|
||||
if (helpModalEl && window.bootstrap) {
|
||||
new window.bootstrap.Modal(helpModalEl).show();
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initStarButtons() {
|
||||
document.addEventListener("click", function (ev) {
|
||||
var btn = ev.target.closest("[data-star-toggle]");
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
var itemType = btn.dataset.itemType;
|
||||
var itemId = btn.dataset.itemId;
|
||||
var active = btn.classList.contains("active");
|
||||
var form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = active ? "/favorites/unstar" : "/favorites/star";
|
||||
form.style.display = "none";
|
||||
[["item_type", itemType], ["item_id", itemId], ["csrf_token", window.vsCSRFToken || ""], ["next", location.pathname]].forEach(function (pair) {
|
||||
var input = document.createElement("input");
|
||||
input.name = pair[0];
|
||||
input.value = pair[1];
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initTheme();
|
||||
var player = initPlayer();
|
||||
initKeyboardShortcuts(player);
|
||||
initStarButtons();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,51 @@
|
||||
{{define "admin_folders"}}{{template "header" .}}
|
||||
<div class="card mb-4" style="max-width: 600px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Add music folder</h2>
|
||||
<form method="post" action="/admin/folders">
|
||||
{{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-5"><input type="text" name="path" class="form-control form-control-sm" placeholder="/music" required></div>
|
||||
<div class="col-3">
|
||||
<select name="kind" class="form-select form-select-sm">
|
||||
<option value="music">Music</option>
|
||||
<option value="audiobooks">Audiobooks</option>
|
||||
<option value="podcasts">Podcasts</option>
|
||||
<option value="video">Video</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm mt-2">Add folder</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table vs-table table-sm">
|
||||
<thead><tr><th>Name</th><th>Path</th><th>Kind</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td class="text-secondary">{{.Path}}</td>
|
||||
<td>{{.Kind}}</td>
|
||||
<td>{{if .Enabled}}<span class="text-success">Enabled</span>{{else}}<span class="text-secondary">Disabled</span>{{end}}</td>
|
||||
<td class="text-end">
|
||||
<form method="post" action="/admin/folders/{{.ID}}/scan" class="d-inline">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Scan now</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/folders/{{.ID}}/toggle" class="d-inline">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">{{if .Enabled}}Disable{{else}}Enable{{end}}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/folders/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Remove this folder? Its indexed songs will be deleted.');">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,28 @@
|
||||
{{define "admin_scanner"}}{{template "header" .}}
|
||||
<table class="table vs-table table-sm">
|
||||
<thead><tr><th>Folder</th><th>Status</th><th>Seen</th><th>Added</th><th>Updated</th><th>Removed</th><th>Last full scan</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data}}
|
||||
<tr>
|
||||
<td>{{.Folder.Name}}</td>
|
||||
<td>
|
||||
{{if eq .State.Status "scanning"}}<span class="text-primary"><i class="bi bi-arrow-repeat"></i> Scanning…</span>
|
||||
{{else if eq .State.Status "error"}}<span class="text-danger" title="{{.State.ErrorMessage}}"><i class="bi bi-exclamation-triangle"></i> Error</span>
|
||||
{{else}}<span class="text-success">Idle</span>{{end}}
|
||||
</td>
|
||||
<td>{{.State.FilesSeen}}</td>
|
||||
<td>{{.State.FilesAdded}}</td>
|
||||
<td>{{.State.FilesUpdated}}</td>
|
||||
<td>{{.State.FilesRemoved}}</td>
|
||||
<td class="text-secondary small">{{if .State.LastFullScanAt.Valid}}{{.State.LastFullScanAt.String}}{{else}}never{{end}}</td>
|
||||
<td class="text-end">
|
||||
<form method="post" action="/admin/folders/{{.Folder.ID}}/scan">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary" {{if eq .State.Status "scanning"}}disabled{{end}}>Scan now</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,39 @@
|
||||
{{define "admin_settings"}}{{template "header" .}}
|
||||
<div class="card" style="max-width: 600px;">
|
||||
<div class="card-body">
|
||||
<form method="post" action="/admin/settings">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Scan interval</label>
|
||||
<input type="text" class="form-control" name="scan_interval" value="{{.Data.ScanInterval}}" placeholder="1h">
|
||||
<div class="form-text">Go duration syntax, e.g. 30m, 1h, 6h.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">ffmpeg path</label>
|
||||
<input type="text" class="form-control" name="ffmpeg_path" value="{{.Data.FFmpegPath}}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">ffprobe path</label>
|
||||
<input type="text" class="form-control" name="ffprobe_path" value="{{.Data.FFprobePath}}">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3" style="max-width: 600px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">External authentication</h2>
|
||||
<p class="text-secondary small mb-2">
|
||||
Configured via <code>VIBESONIC_EXTAUTH_*</code> environment variables (restart required to change).
|
||||
</p>
|
||||
<table class="table table-sm vs-table mb-0">
|
||||
<tbody>
|
||||
<tr><td>Enabled</td><td>{{if .Data.ExtAuthEnabled}}Yes{{else}}No{{end}}</td></tr>
|
||||
<tr><td>Trusted sources</td><td>{{if .Data.ExtAuthTrustedSources}}{{.Data.ExtAuthTrustedSources}}{{else}}<span class="text-secondary">none configured</span>{{end}}</td></tr>
|
||||
<tr><td>Header</td><td>{{.Data.ExtAuthHeader}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "admin_users"}}{{template "header" .}}
|
||||
<div class="card mb-4" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Add user</h2>
|
||||
<form method="post" action="/admin/users">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><input type="text" name="username" class="form-control form-control-sm" placeholder="Username" required></div>
|
||||
<div class="col-6"><input type="password" name="password" class="form-control form-control-sm" placeholder="Password" minlength="8" required></div>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="is_admin" id="is_admin" value="1">
|
||||
<label class="form-check-label small" for="is_admin">Administrator</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm mt-2">Add user</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table vs-table table-sm">
|
||||
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data}}
|
||||
<tr>
|
||||
<td>{{.Username}}{{if .ExternalAuth}} <span class="badge text-bg-secondary">external</span>{{end}}</td>
|
||||
<td class="text-secondary">{{.Email}}</td>
|
||||
<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">
|
||||
<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>
|
||||
</form>
|
||||
<form method="post" action="/admin/users/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this user?');">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{define "album"}}{{template "header" .}}
|
||||
<div class="d-flex gap-3 mb-4">
|
||||
<img class="vs-card-art" style="width:180px;" src="{{if .Data.Album.CoverArtID}}/cover-art/{{.Data.Album.CoverArtID}}?size=400{{else}}/static/img/default-art.svg{{end}}" alt="">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">{{.Data.Album.Title}}</h2>
|
||||
<div class="text-secondary mb-2">{{.Data.Album.ArtistName}}{{if .Data.Album.Year}} · {{.Data.Album.Year}}{{end}}</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{{template "song_rows" .Data.Songs}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,11 @@
|
||||
{{define "albums"}}{{template "header" .}}
|
||||
<div class="vs-grid mb-3">
|
||||
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
|
||||
</div>
|
||||
{{if not .Data.Albums}}<p class="text-secondary">No albums yet.</p>{{end}}
|
||||
{{if .Data.HasMore}}
|
||||
<div class="text-center mt-3">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="?offset={{.Data.NextOffset}}">Load more</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,6 @@
|
||||
{{define "artist"}}{{template "header" .}}
|
||||
<div class="vs-grid mb-3">
|
||||
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
|
||||
</div>
|
||||
{{if not .Data.Albums}}<p class="text-secondary">No albums for this artist yet.</p>{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "artists"}}{{template "header" .}}
|
||||
{{if not .Data}}
|
||||
<p class="text-secondary">No artists yet.</p>
|
||||
{{else}}
|
||||
<div class="vs-grid">
|
||||
{{range .Data}}{{template "artist_card" .}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,10 @@
|
||||
{{define "favorites"}}{{template "header" .}}
|
||||
<h2 class="h6 text-secondary">Artists</h2>
|
||||
{{if .Data.Artists}}<div class="vs-grid mb-4">{{range .Data.Artists}}{{template "artist_card" .}}{{end}}</div>{{else}}<p class="text-secondary small">No favorite artists yet.</p>{{end}}
|
||||
|
||||
<h2 class="h6 text-secondary">Albums</h2>
|
||||
{{if .Data.Albums}}<div class="vs-grid mb-4">{{range .Data.Albums}}{{template "album_card" .}}{{end}}</div>{{else}}<p class="text-secondary small">No favorite albums yet.</p>{{end}}
|
||||
|
||||
<h2 class="h6 text-secondary">Songs</h2>
|
||||
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary small">No favorite songs yet.</p>{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "genres"}}{{template "header" .}}
|
||||
{{if .Data}}
|
||||
<div class="list-group">
|
||||
{{range .Data}}<a class="list-group-item list-group-item-action" href="/songs?genre={{.ID}}">{{.Name}}</a>{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-secondary">No genres yet.</p>
|
||||
{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{define "home"}}{{template "header" .}}
|
||||
{{if not .Data.HasLibrary}}
|
||||
<div class="alert alert-info">
|
||||
Your library is empty so far. <a href="/admin/folders">Add a music folder</a> to get started.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h2 class="h6 text-secondary">Recently added</h2>
|
||||
<div class="vs-grid mb-4">
|
||||
{{range .Data.RecentlyAdded}}{{template "album_card" .}}{{end}}
|
||||
</div>
|
||||
|
||||
<h2 class="h6 text-secondary">Recently played</h2>
|
||||
<div class="vs-grid">
|
||||
{{range .Data.RecentlyPlayedAlbums}}{{template "album_card" .}}{{end}}
|
||||
</div>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "login"}}<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Log in · VibeSonic</title>
|
||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/css/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body class="d-flex align-items-center justify-content-center" style="min-height:100vh;">
|
||||
<div class="card" style="width: 22rem;">
|
||||
<div class="card-body">
|
||||
<h1 class="h4 text-center mb-3"><i class="bi bi-vinyl-fill"></i> VibeSonic</h1>
|
||||
{{if .Flash}}<div class="alert alert-{{if .FlashKind}}{{.FlashKind}}{{else}}danger{{end}}">{{.Flash}}</div>{{end}}
|
||||
<form method="post" action="/login">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<input type="hidden" name="next" value="{{.Data.Next}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" autofocus required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Log in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,39 @@
|
||||
{{define "album_card"}}
|
||||
<a href="/albums/{{.ID}}" class="vs-card">
|
||||
<img class="vs-card-art" src="{{if .CoverArtID}}/cover-art/{{.CoverArtID}}?size=300{{else}}/static/img/default-art.svg{{end}}" alt="" loading="lazy">
|
||||
<div class="vs-card-title">{{.Title}}</div>
|
||||
<div class="vs-card-subtitle">{{.ArtistName}}</div>
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{define "artist_card"}}
|
||||
<a href="/artists/{{.ID}}" class="vs-card">
|
||||
<img class="vs-card-art" style="border-radius:50%;" src="/static/img/default-art.svg" alt="" loading="lazy">
|
||||
<div class="vs-card-title">{{.Name}}</div>
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{define "song_rows"}}
|
||||
<div class="table-responsive" data-playable-list>
|
||||
<table class="vs-table vs-table-hover table mb-0">
|
||||
<tbody>
|
||||
{{range $i, $s := .}}
|
||||
<tr class="vs-playable"
|
||||
data-song-id="{{$s.ID}}"
|
||||
data-title="{{$s.Title}}"
|
||||
data-artist="{{$s.ArtistName}}"
|
||||
data-album="{{$s.AlbumTitle}}"
|
||||
data-cover="{{$s.CoverArtID}}"
|
||||
data-starred="{{if $s.Starred}}1{{else}}0{{end}}">
|
||||
<td class="vs-track-num">{{if $s.TrackNumber}}{{$s.TrackNumber}}{{else}}{{add $i 1}}{{end}}</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{$s.Title}}</div>
|
||||
{{if $s.AlbumTitle}}<div class="text-secondary small">{{$s.ArtistName}} — {{$s.AlbumTitle}}</div>{{else}}<div class="text-secondary small">{{$s.ArtistName}}</div>{{end}}
|
||||
</td>
|
||||
<td class="text-end text-secondary small">{{fmtDuration $s.DurationSeconds}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,91 @@
|
||||
{{define "footer"}}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{{if .User}}{{template "player"}}{{end}}
|
||||
{{template "shortcuts_modal"}}
|
||||
{{template "confirm_delete_modal"}}
|
||||
|
||||
<script src="/static/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "player"}}
|
||||
<audio id="vs-audio" preload="metadata"></audio>
|
||||
<div class="vs-player">
|
||||
<img id="vs-player-art" class="vs-player-art" src="/static/img/default-art.svg" alt="">
|
||||
<div class="vs-player-meta">
|
||||
<div id="vs-player-title" class="vs-player-title">Nothing playing</div>
|
||||
<div id="vs-player-artist" class="vs-player-artist"></div>
|
||||
</div>
|
||||
<div class="vs-player-controls">
|
||||
<div class="vs-player-buttons">
|
||||
<button id="vs-prev-btn" type="button" class="vs-player-btn" title="Previous (J)"><i class="bi bi-skip-start-fill"></i></button>
|
||||
<button id="vs-play-btn" type="button" class="vs-player-btn vs-play" title="Play/Pause (Space)"><i class="bi bi-play-fill"></i></button>
|
||||
<button id="vs-next-btn" type="button" class="vs-player-btn" title="Next (L)"><i class="bi bi-skip-end-fill"></i></button>
|
||||
</div>
|
||||
<div class="vs-player-timeline">
|
||||
<span id="vs-current-time" class="vs-player-time">0:00</span>
|
||||
<input id="vs-progress" type="range" class="form-range" min="0" max="100" value="0">
|
||||
<span id="vs-duration" class="vs-player-time">0:00</span>
|
||||
</div>
|
||||
<button id="vs-now-star" type="button" class="vs-star-btn" data-star-toggle data-item-type="song" data-item-id="" title="Favorite">
|
||||
<i class="bi bi-star"></i>
|
||||
</button>
|
||||
<div class="vs-player-volume">
|
||||
<button id="vs-mute-btn" type="button" class="vs-player-btn" title="Mute (M)"><i class="bi bi-volume-up"></i></button>
|
||||
<input id="vs-volume" type="range" class="form-range" min="0" max="1" step="0.05" value="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "shortcuts_modal"}}
|
||||
<div class="modal fade" id="vs-shortcuts-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-keyboard"></i> Keyboard shortcuts</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
<tr><td><kbd>Space</kbd> / <kbd>K</kbd></td><td>Play / Pause</td></tr>
|
||||
<tr><td><kbd>J</kbd></td><td>Previous track</td></tr>
|
||||
<tr><td><kbd>L</kbd></td><td>Next track</td></tr>
|
||||
<tr><td><kbd>←</kbd> / <kbd>→</kbd></td><td>Seek -5s / +5s</td></tr>
|
||||
<tr><td><kbd>↑</kbd> / <kbd>↓</kbd></td><td>Volume up / down</td></tr>
|
||||
<tr><td><kbd>M</kbd></td><td>Mute</td></tr>
|
||||
<tr><td><kbd>/</kbd></td><td>Focus search</td></tr>
|
||||
<tr><td><kbd>?</kbd></td><td>This help</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "confirm_delete_modal"}}
|
||||
<div class="modal fade" id="vs-confirm-delete-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-exclamation-triangle text-danger"></i> Confirm</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">Are you sure?</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" data-confirm-action data-bs-dismiss="modal">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "csrf_field"}}<input type="hidden" name="csrf_token" value="{{.}}">{{end}}
|
||||
@@ -0,0 +1,98 @@
|
||||
{{define "header"}}<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · VibeSonic</title>
|
||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/css/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var saved = localStorage.getItem("vs-theme");
|
||||
if (saved === "light" || saved === "dark") {
|
||||
document.documentElement.setAttribute("data-bs-theme", saved);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<script>window.vsCSRFToken = {{.CSRFToken}};</script>
|
||||
<header class="vs-topbar d-flex align-items-center justify-content-between">
|
||||
<a href="/" class="vs-brand d-flex align-items-center gap-2">
|
||||
<i class="bi bi-vinyl-fill"></i>
|
||||
<span>VibeSonic</span>
|
||||
</a>
|
||||
<form action="/search" method="get" class="mx-auto" style="max-width: 420px; width: 100%;">
|
||||
<input id="vs-search-input" type="search" name="q" class="form-control form-control-sm" placeholder="Search (press /)" autocomplete="off">
|
||||
</form>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="btn vs-icon-btn" title="Toggle theme" data-theme-toggle>
|
||||
<i class="bi bi-moon-stars"></i>
|
||||
</button>
|
||||
{{if .User}}
|
||||
<div class="dropdown">
|
||||
<button class="btn vs-icon-btn dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><h6 class="dropdown-header">{{.User.Username}}</h6></li>
|
||||
<li><a class="dropdown-item" href="/settings"><i class="bi bi-gear me-2"></i>Settings</a></li>
|
||||
{{if .User.IsAdmin}}<li><a class="dropdown-item" href="/admin"><i class="bi bi-shield-lock me-2"></i>Admin</a></li>{{end}}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<form action="/logout" method="post" class="px-3">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary w-100">Log out</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</header>
|
||||
<div class="vs-shell">
|
||||
<nav class="vs-sidebar d-flex flex-column">
|
||||
<ul class="nav flex-column vs-nav">
|
||||
<li class="nav-item"><a class="nav-link {{if eq .Active "home"}}active{{end}}" href="/"><i class="bi bi-house-door"></i> Home</a></li>
|
||||
<li class="nav-item"><a class="nav-link {{if eq .Active "search"}}active{{end}}" href="/search"><i class="bi bi-search"></i> Search</a></li>
|
||||
|
||||
<li class="vs-nav-label">Library</li>
|
||||
<li class="nav-item"><a class="nav-link {{if eq .Active "artists"}}active{{end}}" href="/artists"><i class="bi bi-person-badge"></i> Artists</a></li>
|
||||
<li class="nav-item"><a class="nav-link {{if eq .Active "albums"}}active{{end}}" href="/albums"><i class="bi bi-disc"></i> Albums</a></li>
|
||||
<li class="nav-item"><a class="nav-link {{if eq .Active "songs"}}active{{end}}" href="/songs"><i class="bi bi-music-note"></i> Songs</a></li>
|
||||
<li class="nav-item"><a class="nav-link {{if eq .Active "genres"}}active{{end}}" href="/genres"><i class="bi bi-tags"></i> Genres</a></li>
|
||||
|
||||
<li class="vs-nav-label">You</li>
|
||||
<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>
|
||||
|
||||
{{if and .User .User.IsAdmin}}
|
||||
<li class="vs-nav-label">Admin</li>
|
||||
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_users"}}active{{end}}" href="/admin/users"><i class="bi bi-people"></i> Users</a></li>
|
||||
<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>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="mt-auto px-3 py-2 text-secondary small">
|
||||
<button type="button" class="btn btn-sm btn-link p-0 text-decoration-none text-secondary" data-bs-toggle="modal" data-bs-target="#vs-shortcuts-modal">
|
||||
<i class="bi bi-keyboard"></i> Shortcuts
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="vs-main">
|
||||
<div class="vs-page-header d-flex align-items-center justify-content-between">
|
||||
<h1 class="h4 mb-0">{{.Title}}</h1>
|
||||
</div>
|
||||
<div class="vs-content">
|
||||
{{if .Flash}}
|
||||
<div class="alert alert-{{if .FlashKind}}{{.FlashKind}}{{else}}success{{end}} alert-dismissible fade show" role="alert">
|
||||
{{.Flash}}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{define "playlist"}}{{template "header" .}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-0">{{.Data.Playlist.Name}}</h2>
|
||||
{{if .Data.Playlist.Description}}<div class="text-secondary small">{{.Data.Playlist.Description}}</div>{{end}}
|
||||
</div>
|
||||
{{if .Data.Owned}}
|
||||
<form method="post" action="/playlists/{{.Data.Playlist.ID}}/delete" onsubmit="return confirm('Delete this playlist?');">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i> Delete</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary">This playlist is empty.</p>{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{define "playlists"}}{{template "header" .}}
|
||||
<form method="post" action="/playlists" class="row g-2 mb-4" style="max-width: 480px;">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="col-8">
|
||||
<input type="text" name="name" class="form-control" placeholder="New playlist name" required>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus-lg"></i> Create</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{if .Data}}
|
||||
<div class="list-group">
|
||||
{{range .Data}}
|
||||
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href="/playlists/{{.ID}}">
|
||||
<span><i class="bi bi-list-ul me-2"></i>{{.Name}}{{if .IsPublic}} <span class="badge text-bg-secondary">public</span>{{end}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-secondary">No playlists yet.</p>
|
||||
{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,3 @@
|
||||
{{define "recent"}}{{template "header" .}}
|
||||
{{if .Data}}{{template "song_rows" .Data}}{{else}}<p class="text-secondary">Nothing played yet.</p>{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,16 @@
|
||||
{{define "search"}}{{template "header" .}}
|
||||
<form method="get" action="/search" class="mb-4">
|
||||
<input type="search" name="q" class="form-control" placeholder="Search artists, albums, songs..." value="{{.Data.Query}}" autofocus>
|
||||
</form>
|
||||
|
||||
{{if .Data.Query}}
|
||||
<h2 class="h6 text-secondary">Artists</h2>
|
||||
{{if .Data.Artists}}<div class="vs-grid mb-4">{{range .Data.Artists}}{{template "artist_card" .}}{{end}}</div>{{else}}<p class="text-secondary small">No matching artists.</p>{{end}}
|
||||
|
||||
<h2 class="h6 text-secondary">Albums</h2>
|
||||
{{if .Data.Albums}}<div class="vs-grid mb-4">{{range .Data.Albums}}{{template "album_card" .}}{{end}}</div>{{else}}<p class="text-secondary small">No matching albums.</p>{{end}}
|
||||
|
||||
<h2 class="h6 text-secondary">Songs</h2>
|
||||
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary small">No matching songs.</p>{{end}}
|
||||
{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,83 @@
|
||||
{{define "settings"}}{{template "header" .}}
|
||||
<div class="row g-4" style="max-width: 900px;">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Change password</h2>
|
||||
<form method="post" action="/settings/password">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Current password</label>
|
||||
<input type="password" class="form-control" name="current_password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New password</label>
|
||||
<input type="password" class="form-control" name="new_password" minlength="8" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Update password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">API keys</h2>
|
||||
<p class="text-secondary small">Used by third-party Subsonic clients in later phases.</p>
|
||||
<form method="post" action="/settings/api-keys" class="d-flex gap-2 mb-3">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<input type="text" name="name" class="form-control form-control-sm" placeholder="Key name">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm text-nowrap">New key</button>
|
||||
</form>
|
||||
{{if .Data.NewAPIKey}}
|
||||
<div class="alert alert-warning small">
|
||||
Copy this key now — it won't be shown again:<br>
|
||||
<code>{{.Data.NewAPIKey}}</code>
|
||||
</div>
|
||||
{{end}}
|
||||
<table class="table table-sm vs-table mb-0">
|
||||
<tbody>
|
||||
{{range .Data.APIKeys}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td class="text-secondary small">{{fmtTime .CreatedAt}}</td>
|
||||
<td class="text-end">
|
||||
<form method="post" action="/settings/api-keys/{{.ID}}/revoke">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Active sessions</h2>
|
||||
<table class="table table-sm vs-table mb-0">
|
||||
<tbody>
|
||||
{{range .Data.Sessions}}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="small">{{.UserAgent}}</div>
|
||||
<div class="text-secondary small">{{.IPAddress}} · since {{fmtTime .CreatedAt}}</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<form method="post" action="/settings/sessions/{{.ID}}/revoke">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,51 @@
|
||||
{{define "setup"}}<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Setup · VibeSonic</title>
|
||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/css/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body class="d-flex align-items-center justify-content-center" style="min-height:100vh;">
|
||||
<div class="card" style="width: 28rem;">
|
||||
<div class="card-body">
|
||||
<h1 class="h4 mb-1"><i class="bi bi-vinyl-fill"></i> Welcome to VibeSonic</h1>
|
||||
<p class="text-secondary small mb-3">Let's create your administrator account and your first music folder.</p>
|
||||
{{if .Flash}}<div class="alert alert-{{if .FlashKind}}{{.FlashKind}}{{else}}danger{{end}}">{{.Flash}}</div>{{end}}
|
||||
<form method="post" action="/setup">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
|
||||
<h2 class="h6 mt-2">Administrator account</h2>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" autofocus required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" minlength="8" required>
|
||||
</div>
|
||||
|
||||
<h2 class="h6 mt-3">First music folder</h2>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="folder_name">Name</label>
|
||||
<input type="text" class="form-control" id="folder_name" name="folder_name" value="Music" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="folder_path">Path (on the server)</label>
|
||||
<input type="text" class="form-control" id="folder_path" name="folder_path" placeholder="/music" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 small text-secondary">
|
||||
ffmpeg: {{if .Data.FFmpegFound}}<span class="text-success"><i class="bi bi-check-circle"></i> found at {{.Data.FFmpegPath}}</span>{{else}}<span class="text-warning"><i class="bi bi-exclamation-triangle"></i> not found at {{.Data.FFmpegPath}} — streaming and metadata will be limited</span>{{end}}<br>
|
||||
ffprobe: {{if .Data.FFprobeFound}}<span class="text-success"><i class="bi bi-check-circle"></i> found at {{.Data.FFprobePath}}</span>{{else}}<span class="text-warning"><i class="bi bi-exclamation-triangle"></i> not found at {{.Data.FFprobePath}}</span>{{end}}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">Create account and start scanning</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "songs"}}{{template "header" .}}
|
||||
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary">No songs yet.</p>{{end}}
|
||||
{{if .Data.HasMore}}
|
||||
<div class="text-center mt-3">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="?offset={{.Data.NextOffset}}">Load more</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
Reference in New Issue
Block a user