Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
1ad7950
separate file for routes
LumePart Jun 13, 2026
7899c44
split run state, handlers and events
LumePart Jun 17, 2026
ce03758
split run state, handlers and events
LumePart Jun 17, 2026
e957089
remove functions from server.go
LumePart Jun 17, 2026
e38880b
Fixed bug where playlist cards wouldn't automatically refresh after a…
dammitjeff Jun 18, 2026
d1c0aa0
dead code cleanup
dammitjeff Jun 18, 2026
6b817f6
echo run output to stdout for visibility in docker logs
dammitjeff Jun 18, 2026
198f8fa
create app package for backend configs
LumePart Jun 21, 2026
97b1804
move defs under separate package, move customID regex under defs
LumePart Jun 21, 2026
e96d599
remove defs from backend package
LumePart Jun 21, 2026
9129feb
move files under separate packages, add backend util functions
LumePart Jun 21, 2026
f012095
move path template handling under settings
LumePart Jun 21, 2026
c02373a
Merge pull request #190 from dammitjeff/refresh-playlist-fix
LumePart Jun 22, 2026
c59caf1
Merge pull request #192 from dammitjeff/echo-logs-to-docker
LumePart Jun 22, 2026
894d2e3
separate package for auth, fix session migration
LumePart Jun 24, 2026
91a48d7
Ensure playlist schedules in .env are up to date in memory
BlakeAlvarez Jun 25, 2026
68ebe09
separate handlers
LumePart Jun 25, 2026
8c44c2d
Merge pull request #196 from BlakeAlvarez/fix-playlist-toggle-lock
LumePart Jun 25, 2026
70f080c
replace package config with app.Config
LumePart Jun 25, 2026
d6c2fee
linter fix
LumePart Jun 25, 2026
31a9a2a
separate file for routes
LumePart Jun 13, 2026
efba3d5
split run state, handlers and events
LumePart Jun 17, 2026
9fca132
split run state, handlers and events
LumePart Jun 17, 2026
7cd5eb0
remove functions from server.go
LumePart Jun 17, 2026
97ab12f
create app package for backend configs
LumePart Jun 21, 2026
9400a4c
move defs under separate package, move customID regex under defs
LumePart Jun 21, 2026
d513ae7
remove defs from backend package
LumePart Jun 21, 2026
07c6a8c
move files under separate packages, add backend util functions
LumePart Jun 21, 2026
3280c13
move path template handling under settings
LumePart Jun 21, 2026
eb0fc83
separate package for auth, fix session migration
LumePart Jun 24, 2026
4f27754
separate handlers
LumePart Jun 25, 2026
b8c9b51
replace package config with app.Config
LumePart Jun 25, 2026
5c2ef1e
rebase
LumePart Jun 29, 2026
95c4e2a
rebase
LumePart Jun 29, 2026
de91893
fix linter
LumePart Jun 29, 2026
b5e049f
remove log funcs from server.go
LumePart Jun 29, 2026
b380621
add context to log
LumePart Jun 29, 2026
e4812c4
remove redundant func
LumePart Jun 29, 2026
33746b7
add fix lost with rebase
LumePart Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions src/main/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"explo/src/logging"
"explo/src/models"
"explo/src/web/backend"
"explo/src/web/backend/playlist"
"fmt"
"log"
"log/slog"
Expand Down Expand Up @@ -165,6 +166,8 @@ func main() {
log.Fatal(srv.Start())
}

slog.Info("Pulling playlist", "playlist", cfg.Flags.Playlist)

var tracks []*models.Track
var err error
if strings.HasPrefix(cfg.Flags.Playlist, "custom-") {
Expand All @@ -178,11 +181,15 @@ func main() {
tracks, err = disc.Discover()
}

if err != nil {
if err != nil {
slog.Error(err.Error(), "notify", true)
os.Exit(1)
}
allTracks := append([]*models.Track(nil), tracks...)
if cfg.ServerCfg.WebDataDir != "" {
playlist.WritePlaylistCache(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist, allTracks, nil)
slog.Info("Saved playlist", "playlist", cfg.Flags.Playlist, "tracks", len(allTracks))
}

client, err := client.NewClient(&cfg)
if err != nil {
Expand Down Expand Up @@ -217,14 +224,6 @@ func main() {
}
}

if cfg.ServerCfg.Enabled {
added := make(map[string]bool)
for _, t := range tracks {
added[t.CleanTitle+"|"+t.Artist] = true
}
backend.WritePlaylistCache(cfg.Flags.CfgPath, cfg.Flags.Playlist, allTracks, added)
}

if err := client.CreatePlaylist(tracks); err != nil {
slog.Warn(err.Error())
} else {
Expand All @@ -240,15 +239,15 @@ func uploadCustomPlaylistArtwork(cfg *config.Config, c *client.Client) {
if !strings.HasPrefix(cfg.Flags.Playlist, "custom-") {
return
}
cp := backend.GetCustomPlaylist(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist)
cp := playlist.GetCustomPlaylist(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist)
if cp == nil || cp.ArtworkURL == "" || cp.ArtworkUploaded {
return
}
uploader, ok := c.API.(client.ArtworkUploader)
if !ok {
return
}
path := backend.CustomPlaylistArtworkPath(cfg.ServerCfg.WebDataDir, cp.ID)
path := playlist.CustomPlaylistArtworkPath(cfg.ServerCfg.WebDataDir, cp.ID)
if _, err := os.Stat(path); err != nil {
slog.Warn("custom-playlists: artwork not cached locally, skipping upload", "id", cp.ID, "path", path)
return
Expand All @@ -257,7 +256,7 @@ func uploadCustomPlaylistArtwork(cfg *config.Config, c *client.Client) {
slog.Warn("custom-playlists: failed to upload playlist artwork", "id", cp.ID, "err", err.Error())
return
}
if err := backend.MarkCustomPlaylistArtworkUploaded(cfg.ServerCfg.WebDataDir, cp.ID); err != nil {
if err := playlist.MarkCustomPlaylistArtworkUploaded(cfg.ServerCfg.WebDataDir, cp.ID); err != nil {
slog.Warn("custom-playlists: artwork upload succeeded but flag not persisted", "id", cp.ID, "err", err.Error())
return
}
Expand Down
50 changes: 50 additions & 0 deletions src/util/backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package util

import (
"strings"
"os/exec"
"os"
"log/slog"

"explo/src/web/backend/app"
)

// customEnvPrefix converts a playlist name like "Today's Hits"
// to an env-var prefix like "CUSTOM_TODAYS_HITS".
// Non-alphanumeric characters are collapsed into underscores.
func CustomEnvPrefix(name string) string {
var b strings.Builder
prevUnderscore := true // start true so leading separators are skipped
for _, r := range strings.ToUpper(name) {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
prevUnderscore = false
} else if !prevUnderscore {
b.WriteRune('_')
prevUnderscore = true
}
}
return "CUSTOM_" + strings.TrimRight(b.String(), "_")
}

// triggerLibraryRefresh spawns the CLI with --refresh-only in the background to
// nudge the configured media server's library scan. Fire-and-forget: errors are
// logged but do not block the caller.
func TriggerLibraryRefresh(cfg app.Config) {
go func() {
cmd := exec.Command(cfg.ExploPath, "--refresh-only", "--config", cfg.WebEnvPath)
env := make([]string, 0, len(os.Environ()))
for _, e := range os.Environ() {
if !strings.HasPrefix(e, "WEB_UI=") {
env = append(env, e)
}
}
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
slog.Warn("library refresh failed", "err", err.Error(), "output", string(out))
return
}
slog.Info("library refresh complete")
}()
}
8 changes: 8 additions & 0 deletions src/web/backend/app/app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package app


type Config struct {
WebEnvPath string
WebDataDir string
ExploPath string
}
17 changes: 17 additions & 0 deletions src/web/backend/app/paths.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package app

import(
"path/filepath"
)

func (c Config) CacheDir() string {
return filepath.Join(c.WebDataDir, "cache")
}

func (c Config) CoversDir() string {
return filepath.Join(c.CacheDir(), "covers")
}

func (c Config) LogsDir() string {
return filepath.Join(c.WebDataDir, "logs")
}
2 changes: 1 addition & 1 deletion src/web/backend/auth.go → src/web/backend/auth/auth.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package backend
package auth

import (
"net/http"
Expand Down
70 changes: 70 additions & 0 deletions src/web/backend/auth/handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package auth

import (
"net/http"
"log/slog"
"encoding/json"
)

func (a *AuthStore) HandleAuthStatus(w http.ResponseWriter, r *http.Request) {
sess := a.sessionManager.GetSession(r)
auth, _ := sess.Get("authenticated").(bool)
if !auth {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
}

func (a *AuthStore) HandleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
err := http.StatusMethodNotAllowed
http.Error(w, "Invalid request method", err)
return
}

if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}

username := r.FormValue("username")
password := r.FormValue("password")

if !a.CompareCreds(username, password) {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
sess := a.sessionManager.GetSession(r)
sess.Put("authenticated", true)
sess.Put("username", username)

if err := a.sessionManager.Migrate(sess); err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}

w.WriteHeader(http.StatusOK)
slog.Info("successful login", "user", username)
}

func (a *AuthStore) HandleLogout(w http.ResponseWriter, r *http.Request) {
sess := a.sessionManager.GetSession(r)
sess.Delete("authenticated")
sess.Delete("username")
w.WriteHeader(http.StatusOK)
}

func (a *AuthStore) HandleCSRF(w http.ResponseWriter, r *http.Request) {
session := a.sessionManager.GetSession(r)

token, _ := session.Get("csrf_token").(string)

w.Header().Set("Content-Type", "application/json")

if err := json.NewEncoder(w).Encode(map[string]string{
"csrf_token": token,
}); err != nil {
slog.Error("failed encoding token to http", "msg", err.Error())
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package backend
package auth

import (
"fmt"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package backend
package auth

import (
"context"
Expand Down
32 changes: 27 additions & 5 deletions src/web/backend/defs.go → src/web/backend/defs/defs.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
// place for confs/variables in use by the UI
package defs

package backend
import (
"regexp"
)
// place for confs/variables in use by the UI or backend


// Custom playlist regex validation
var CustomIDRe = regexp.MustCompile(`^custom-[a-z0-9]+$`)

// configFields is the single source of truth for the settings this web UI
// currently owns. VisibleWhen / RequiredWhen drive the settings UI; the wizard
Expand Down Expand Up @@ -113,6 +120,21 @@ package backend
},
} */

// Option is a value/label pair for select-type fields.
type Option struct {
Value string `json:"value"`
Label string `json:"label"`
}

// Condition expresses a dependency on another field's value.
// All non-zero properties are ANDed together.
type Condition struct {
Field string `json:"field"`
Eq string `json:"eq,omitempty"` // field === value
In []string `json:"in,omitempty"` // field is one of values
Contains string `json:"contains,omitempty"` // value appears in field's comma-separated list
}

// FieldDef describes a single configurable env var.
// Injected into the page as window.__FIELDS__ for the settings UI to consume.
type FieldDef struct {
Expand Down Expand Up @@ -140,15 +162,15 @@ type playlistDef struct {
DefaultFlags string // CLI flags for the run
}

var playlistDefs = map[string]playlistDef{
var PlaylistDefs = map[string]playlistDef{
"weekly-exploration": {"WEEKLY_EXPLORATION", "15 00 * * 2", "--playlist weekly-exploration"},
"weekly-jams": {"WEEKLY_JAMS", "30 00 * * 1", "--playlist weekly-jams"},
"daily-jams": {"DAILY_JAMS", "15 01 * * *", "--playlist daily-jams"},
"on-repeat": {"ON_REPEAT", "0 12 1 * *", "--playlist on-repeat"},
}

// allConfigKeys is the complete set of env keys the web UI reads and writes.
var allConfigKeys = []string{
var AllConfigKeys = []string{
"LISTENBRAINZ_USER", "LISTENBRAINZ_DISCOVERY",
"WEEKLY_EXPLORATION_SCHEDULE", "WEEKLY_EXPLORATION_FLAGS",
"WEEKLY_JAMS_SCHEDULE", "WEEKLY_JAMS_FLAGS",
Expand All @@ -160,4 +182,4 @@ var allConfigKeys = []string{
"DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST",
"SLSKD_URL", "SLSKD_API_KEY",
"WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS",
}
}
55 changes: 55 additions & 0 deletions src/web/backend/handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package backend

import (
"os"
"net/http"
"path/filepath"
"log/slog"
"encoding/json"
"strings"
)

// handleGetLog returns the contents of the rolling log file.
func (s *Server) handleGetLog(w http.ResponseWriter, r *http.Request) {
data, err := os.ReadFile(s.manualRun.LogPath())
if err != nil && !os.IsNotExist(err) {
http.Error(w, "failed to read log", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if _, err := w.Write(data); err != nil {
slog.Error("failed writing http response", "msg", err.Error())
}
}

// handleBrowse returns subdirectories of the requested path for filesystem autocomplete.
func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) {
path := filepath.Clean(r.URL.Query().Get("path"))
if path == "" || path == "." {
path = "/"
}
if !filepath.IsAbs(path) {
http.Error(w, "path must be absolute", http.StatusBadRequest)
return
}

entries, err := os.ReadDir(path)
if err != nil {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode([]string{}); err != nil {
slog.Error("failed to encode empty slice", "msg", err.Error())
}
return
}

dirs := make([]string, 0)
for _, e := range entries {
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
dirs = append(dirs, filepath.Join(path, e.Name()))
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(dirs); err != nil {
slog.Warn("failed to encode directories to response", "err", err.Error())
}
}
Loading
Loading