diff --git a/cmd/vibesonic/main.go b/cmd/vibesonic/main.go new file mode 100644 index 0000000..19623e4 --- /dev/null +++ b/cmd/vibesonic/main.go @@ -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) + } +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..4d62666 --- /dev/null +++ b/internal/app/app.go @@ -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) +} diff --git a/internal/database/coverart.go b/internal/database/coverart.go index 4dfd487..f7df161 100644 --- a/internal/database/coverart.go +++ b/internal/database/coverart.go @@ -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 +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 4c55f1d..a40abc5 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -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). diff --git a/internal/web/admin_handlers.go b/internal/web/admin_handlers.go new file mode 100644 index 0000000..b447d50 --- /dev/null +++ b/internal/web/admin_handlers.go @@ -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") +} diff --git a/internal/web/auth_handlers.go b/internal/web/auth_handlers.go new file mode 100644 index 0000000..ee6c75e --- /dev/null +++ b/internal/web/auth_handlers.go @@ -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) +} diff --git a/internal/web/coverart_handler.go b/internal/web/coverart_handler.go new file mode 100644 index 0000000..e8595ad --- /dev/null +++ b/internal/web/coverart_handler.go @@ -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 +} diff --git a/internal/web/health.go b/internal/web/health.go new file mode 100644 index 0000000..270647e --- /dev/null +++ b/internal/web/health.go @@ -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")) +} diff --git a/internal/web/pages.go b/internal/web/pages.go new file mode 100644 index 0000000..0244b7f --- /dev/null +++ b/internal/web/pages.go @@ -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 +} diff --git a/internal/web/playlists.go b/internal/web/playlists.go new file mode 100644 index 0000000..8dc66bd --- /dev/null +++ b/internal/web/playlists.go @@ -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) +} diff --git a/internal/web/render.go b/internal/web/render.go new file mode 100644 index 0000000..b823744 --- /dev/null +++ b/internal/web/render.go @@ -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) + } +} diff --git a/internal/web/router.go b/internal/web/router.go new file mode 100644 index 0000000..21418fe --- /dev/null +++ b/internal/web/router.go @@ -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) + }) +} diff --git a/internal/web/settings_handlers.go b/internal/web/settings_handlers.go new file mode 100644 index 0000000..89abbe1 --- /dev/null +++ b/internal/web/settings_handlers.go @@ -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) +} diff --git a/internal/web/stream_handler.go b/internal/web/stream_handler.go new file mode 100644 index 0000000..9dac059 --- /dev/null +++ b/internal/web/stream_handler.go @@ -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 + } +} diff --git a/internal/web/views.go b/internal/web/views.go new file mode 100644 index 0000000..550c5df --- /dev/null +++ b/internal/web/views.go @@ -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 +} diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..3ccb9d0 --- /dev/null +++ b/internal/web/web.go @@ -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, + }) +} diff --git a/web/assets.go b/web/assets.go new file mode 100644 index 0000000..2bdc1c8 --- /dev/null +++ b/web/assets.go @@ -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 diff --git a/web/static/css/app.css b/web/static/css/app.css new file mode 100644 index 0000000..ab127d9 --- /dev/null +++ b/web/static/css/app.css @@ -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; + } +} diff --git a/web/static/img/default-art.svg b/web/static/img/default-art.svg new file mode 100644 index 0000000..95d667a --- /dev/null +++ b/web/static/img/default-art.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..5f3c5a5 --- /dev/null +++ b/web/static/js/app.js @@ -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 ? '' : ''; + + 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 + ? '' + : ''; + }; + + 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(); + }); +})(); diff --git a/web/templates/admin_folders.html b/web/templates/admin_folders.html new file mode 100644 index 0000000..4495e1f --- /dev/null +++ b/web/templates/admin_folders.html @@ -0,0 +1,51 @@ +{{define "admin_folders"}}{{template "header" .}} +
+
+

Add music folder

+
+ {{template "csrf_field" .CSRFToken}} +
+
+
+
+ +
+
+ +
+
+
+ + + + + {{range .Data}} + + + + + + + + {{end}} + +
NamePathKindStatus
{{.Name}}{{.Path}}{{.Kind}}{{if .Enabled}}Enabled{{else}}Disabled{{end}} +
+ {{template "csrf_field" $.CSRFToken}} + +
+
+ {{template "csrf_field" $.CSRFToken}} + +
+
+ {{template "csrf_field" $.CSRFToken}} + +
+
+{{template "footer" .}}{{end}} diff --git a/web/templates/admin_scanner.html b/web/templates/admin_scanner.html new file mode 100644 index 0000000..befac09 --- /dev/null +++ b/web/templates/admin_scanner.html @@ -0,0 +1,28 @@ +{{define "admin_scanner"}}{{template "header" .}} + + + + {{range .Data}} + + + + + + + + + + + {{end}} + +
FolderStatusSeenAddedUpdatedRemovedLast full scan
{{.Folder.Name}} + {{if eq .State.Status "scanning"}} Scanning… + {{else if eq .State.Status "error"}} Error + {{else}}Idle{{end}} + {{.State.FilesSeen}}{{.State.FilesAdded}}{{.State.FilesUpdated}}{{.State.FilesRemoved}}{{if .State.LastFullScanAt.Valid}}{{.State.LastFullScanAt.String}}{{else}}never{{end}} +
+ {{template "csrf_field" $.CSRFToken}} + +
+
+{{template "footer" .}}{{end}} diff --git a/web/templates/admin_settings.html b/web/templates/admin_settings.html new file mode 100644 index 0000000..f698c4a --- /dev/null +++ b/web/templates/admin_settings.html @@ -0,0 +1,39 @@ +{{define "admin_settings"}}{{template "header" .}} +
+
+
+ {{template "csrf_field" .CSRFToken}} +
+ + +
Go duration syntax, e.g. 30m, 1h, 6h.
+
+
+ + +
+
+ + +
+ +
+
+
+ +
+
+

External authentication

+

+ Configured via VIBESONIC_EXTAUTH_* environment variables (restart required to change). +

+ + + + + + +
Enabled{{if .Data.ExtAuthEnabled}}Yes{{else}}No{{end}}
Trusted sources{{if .Data.ExtAuthTrustedSources}}{{.Data.ExtAuthTrustedSources}}{{else}}none configured{{end}}
Header{{.Data.ExtAuthHeader}}
+
+
+{{template "footer" .}}{{end}} diff --git a/web/templates/admin_users.html b/web/templates/admin_users.html new file mode 100644 index 0000000..a8e9f28 --- /dev/null +++ b/web/templates/admin_users.html @@ -0,0 +1,43 @@ +{{define "admin_users"}}{{template "header" .}} +
+
+

Add user

+
+ {{template "csrf_field" .CSRFToken}} +
+
+
+
+
+ + +
+ +
+
+
+ + + + + {{range .Data}} + + + + + + + + {{end}} + +
UsernameEmailRoleStatus
{{.Username}}{{if .ExternalAuth}} external{{end}}{{.Email}}{{if .IsAdmin}}Admin{{else}}User{{end}}{{if .Disabled}}Disabled{{else}}Active{{end}} +
+ {{template "csrf_field" $.CSRFToken}} + +
+
+ {{template "csrf_field" $.CSRFToken}} + +
+
+{{template "footer" .}}{{end}} diff --git a/web/templates/album.html b/web/templates/album.html new file mode 100644 index 0000000..b42ebee --- /dev/null +++ b/web/templates/album.html @@ -0,0 +1,13 @@ +{{define "album"}}{{template "header" .}} +
+ +
+

{{.Data.Album.Title}}

+
{{.Data.Album.ArtistName}}{{if .Data.Album.Year}} · {{.Data.Album.Year}}{{end}}
+ +
+
+{{template "song_rows" .Data.Songs}} +{{template "footer" .}}{{end}} diff --git a/web/templates/albums.html b/web/templates/albums.html new file mode 100644 index 0000000..9dbf94c --- /dev/null +++ b/web/templates/albums.html @@ -0,0 +1,11 @@ +{{define "albums"}}{{template "header" .}} +
+ {{range .Data.Albums}}{{template "album_card" .}}{{end}} +
+{{if not .Data.Albums}}

No albums yet.

{{end}} +{{if .Data.HasMore}} +
+ Load more +
+{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/artist.html b/web/templates/artist.html new file mode 100644 index 0000000..6ef4eec --- /dev/null +++ b/web/templates/artist.html @@ -0,0 +1,6 @@ +{{define "artist"}}{{template "header" .}} +
+ {{range .Data.Albums}}{{template "album_card" .}}{{end}} +
+{{if not .Data.Albums}}

No albums for this artist yet.

{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/artists.html b/web/templates/artists.html new file mode 100644 index 0000000..7333e26 --- /dev/null +++ b/web/templates/artists.html @@ -0,0 +1,9 @@ +{{define "artists"}}{{template "header" .}} +{{if not .Data}} +

No artists yet.

+{{else}} +
+ {{range .Data}}{{template "artist_card" .}}{{end}} +
+{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/favorites.html b/web/templates/favorites.html new file mode 100644 index 0000000..16a6acb --- /dev/null +++ b/web/templates/favorites.html @@ -0,0 +1,10 @@ +{{define "favorites"}}{{template "header" .}} +

Artists

+{{if .Data.Artists}}
{{range .Data.Artists}}{{template "artist_card" .}}{{end}}
{{else}}

No favorite artists yet.

{{end}} + +

Albums

+{{if .Data.Albums}}
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
{{else}}

No favorite albums yet.

{{end}} + +

Songs

+{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}

No favorite songs yet.

{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/genres.html b/web/templates/genres.html new file mode 100644 index 0000000..1b6f524 --- /dev/null +++ b/web/templates/genres.html @@ -0,0 +1,9 @@ +{{define "genres"}}{{template "header" .}} +{{if .Data}} +
+ {{range .Data}}{{.Name}}{{end}} +
+{{else}} +

No genres yet.

+{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/home.html b/web/templates/home.html new file mode 100644 index 0000000..6b5de0a --- /dev/null +++ b/web/templates/home.html @@ -0,0 +1,17 @@ +{{define "home"}}{{template "header" .}} +{{if not .Data.HasLibrary}} +
+ Your library is empty so far. Add a music folder to get started. +
+{{end}} + +

Recently added

+
+ {{range .Data.RecentlyAdded}}{{template "album_card" .}}{{end}} +
+ +

Recently played

+
+ {{range .Data.RecentlyPlayedAlbums}}{{template "album_card" .}}{{end}} +
+{{template "footer" .}}{{end}} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..739aa4a --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,33 @@ +{{define "login"}} + + + + + Log in · VibeSonic + + + + + +
+
+

VibeSonic

+ {{if .Flash}}
{{.Flash}}
{{end}} +
+ {{template "csrf_field" .CSRFToken}} + +
+ + +
+
+ + +
+ +
+
+
+ + +{{end}} diff --git a/web/templates/partials/cards.html b/web/templates/partials/cards.html new file mode 100644 index 0000000..ba7f34a --- /dev/null +++ b/web/templates/partials/cards.html @@ -0,0 +1,39 @@ +{{define "album_card"}} + + +
{{.Title}}
+
{{.ArtistName}}
+
+{{end}} + +{{define "artist_card"}} + + +
{{.Name}}
+
+{{end}} + +{{define "song_rows"}} +
+ + + {{range $i, $s := .}} + + + + + + {{end}} + +
{{if $s.TrackNumber}}{{$s.TrackNumber}}{{else}}{{add $i 1}}{{end}} +
{{$s.Title}}
+ {{if $s.AlbumTitle}}
{{$s.ArtistName}} — {{$s.AlbumTitle}}
{{else}}
{{$s.ArtistName}}
{{end}} +
{{fmtDuration $s.DurationSeconds}}
+
+{{end}} diff --git a/web/templates/partials/footer.html b/web/templates/partials/footer.html new file mode 100644 index 0000000..ec40943 --- /dev/null +++ b/web/templates/partials/footer.html @@ -0,0 +1,91 @@ +{{define "footer"}} + + + + +{{if .User}}{{template "player"}}{{end}} +{{template "shortcuts_modal"}} +{{template "confirm_delete_modal"}} + + + + + +{{end}} + +{{define "player"}} + +
+ +
+
Nothing playing
+
+
+
+
+ + + +
+
+ 0:00 + + 0:00 +
+ +
+ + +
+
+
+{{end}} + +{{define "shortcuts_modal"}} + +{{end}} + +{{define "confirm_delete_modal"}} + +{{end}} + +{{define "csrf_field"}}{{end}} diff --git a/web/templates/partials/header.html b/web/templates/partials/header.html new file mode 100644 index 0000000..105a31a --- /dev/null +++ b/web/templates/partials/header.html @@ -0,0 +1,98 @@ +{{define "header"}} + + + + + {{.Title}} · VibeSonic + + + + + + + +
+ + + VibeSonic + +
+ +
+
+ + {{if .User}} + + {{end}} +
+
+
+ +
+
+

{{.Title}}

+
+
+ {{if .Flash}} + + {{end}} +{{end}} diff --git a/web/templates/playlist.html b/web/templates/playlist.html new file mode 100644 index 0000000..bb57eaa --- /dev/null +++ b/web/templates/playlist.html @@ -0,0 +1,15 @@ +{{define "playlist"}}{{template "header" .}} +
+
+

{{.Data.Playlist.Name}}

+ {{if .Data.Playlist.Description}}
{{.Data.Playlist.Description}}
{{end}} +
+ {{if .Data.Owned}} +
+ {{template "csrf_field" .CSRFToken}} + +
+ {{end}} +
+{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}

This playlist is empty.

{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/playlists.html b/web/templates/playlists.html new file mode 100644 index 0000000..fe47360 --- /dev/null +++ b/web/templates/playlists.html @@ -0,0 +1,23 @@ +{{define "playlists"}}{{template "header" .}} +
+ {{template "csrf_field" .CSRFToken}} +
+ +
+
+ +
+
+ +{{if .Data}} +
+ {{range .Data}} + + {{.Name}}{{if .IsPublic}} public{{end}} + + {{end}} +
+{{else}} +

No playlists yet.

+{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/recent.html b/web/templates/recent.html new file mode 100644 index 0000000..e3149c4 --- /dev/null +++ b/web/templates/recent.html @@ -0,0 +1,3 @@ +{{define "recent"}}{{template "header" .}} +{{if .Data}}{{template "song_rows" .Data}}{{else}}

Nothing played yet.

{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/search.html b/web/templates/search.html new file mode 100644 index 0000000..e130822 --- /dev/null +++ b/web/templates/search.html @@ -0,0 +1,16 @@ +{{define "search"}}{{template "header" .}} +
+ +
+ +{{if .Data.Query}} +

Artists

+{{if .Data.Artists}}
{{range .Data.Artists}}{{template "artist_card" .}}{{end}}
{{else}}

No matching artists.

{{end}} + +

Albums

+{{if .Data.Albums}}
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
{{else}}

No matching albums.

{{end}} + +

Songs

+{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}

No matching songs.

{{end}} +{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/settings.html b/web/templates/settings.html new file mode 100644 index 0000000..8582d2e --- /dev/null +++ b/web/templates/settings.html @@ -0,0 +1,83 @@ +{{define "settings"}}{{template "header" .}} +
+
+
+
+

Change password

+
+ {{template "csrf_field" .CSRFToken}} +
+ + +
+
+ + +
+ +
+
+
+ +
+
+

API keys

+

Used by third-party Subsonic clients in later phases.

+
+ {{template "csrf_field" .CSRFToken}} + + +
+ {{if .Data.NewAPIKey}} +
+ Copy this key now — it won't be shown again:
+ {{.Data.NewAPIKey}} +
+ {{end}} + + + {{range .Data.APIKeys}} + + + + + + {{end}} + +
{{.Name}}{{fmtTime .CreatedAt}} +
+ {{template "csrf_field" $.CSRFToken}} + +
+
+
+
+
+ +
+
+
+

Active sessions

+ + + {{range .Data.Sessions}} + + + + + {{end}} + +
+
{{.UserAgent}}
+
{{.IPAddress}} · since {{fmtTime .CreatedAt}}
+
+
+ {{template "csrf_field" $.CSRFToken}} + +
+
+
+
+
+
+{{template "footer" .}}{{end}} diff --git a/web/templates/setup.html b/web/templates/setup.html new file mode 100644 index 0000000..b1aaa53 --- /dev/null +++ b/web/templates/setup.html @@ -0,0 +1,51 @@ +{{define "setup"}} + + + + + Setup · VibeSonic + + + + + +
+
+

Welcome to VibeSonic

+

Let's create your administrator account and your first music folder.

+ {{if .Flash}}
{{.Flash}}
{{end}} +
+ {{template "csrf_field" .CSRFToken}} + +

Administrator account

+
+ + +
+
+ + +
+ +

First music folder

+
+ + +
+
+ + +
+ +
+ ffmpeg: {{if .Data.FFmpegFound}} found at {{.Data.FFmpegPath}}{{else}} not found at {{.Data.FFmpegPath}} — streaming and metadata will be limited{{end}}
+ ffprobe: {{if .Data.FFprobeFound}} found at {{.Data.FFprobePath}}{{else}} not found at {{.Data.FFprobePath}}{{end}} +
+ + +
+
+
+ + +{{end}} diff --git a/web/templates/songs.html b/web/templates/songs.html new file mode 100644 index 0000000..8c97c4a --- /dev/null +++ b/web/templates/songs.html @@ -0,0 +1,8 @@ +{{define "songs"}}{{template "header" .}} +{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}

No songs yet.

{{end}} +{{if .Data.HasMore}} + +{{end}} +{{template "footer" .}}{{end}}