diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..0e40f8a
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+.env
+.git
+.DS_Store
+tmp/
+logs/
+explo
+src/web/frontend/node_modules/
+src/web/dist/
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..974e4a1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+.env
+.DS_Store
+/main
+tmp/
+.air.toml
+logs/
+explo
+src/web/dist/
+src/web/frontend/node_modules/
diff --git a/Dockerfile b/Dockerfile
index 4f7feaf..2fc7bdb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,10 @@
+FROM node:20-alpine AS ui-builder
+WORKDIR /app/src/web/frontend
+COPY src/web/frontend/package*.json ./
+RUN npm ci
+COPY src/web/frontend/ ./
+RUN npm run build
+
FROM golang:1.24-alpine AS builder
# Set the working directory
@@ -6,6 +13,9 @@ WORKDIR /app
# Copy the Go source code into the container
COPY ./ .
+# Copy the built React frontend into the embed path
+COPY --from=ui-builder /app/src/web/dist ./src/web/dist
+
# Build the Go binary based on the target architecture
ARG TARGETARCH
RUN GOOS=linux GOARCH=$TARGETARCH go build -o explo ./src/main/
@@ -35,7 +45,9 @@ COPY src/downloader/youtube_music/search_ytmusic.py .
RUN chmod +x /start.sh ./explo
-# Can be defined from compose as well
-ENV WEEKLY_EXPLORATION_SCHEDULE="15 0 * * 2"
+
+ENV WEB_ADDR=":7288"
+
+EXPOSE 7288
CMD ["/start.sh"]
\ No newline at end of file
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 5e33e5c..a780efd 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -3,6 +3,10 @@ services:
image: ghcr.io/lumepart/explo:latest
restart: unless-stopped
container_name: explo
+ ports:
+ - "7288:7288"
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
volumes:
- /path/to/.env:/opt/explo/.env
- /path/to/musiclibrary/explo:/data/ # has to be in the same path you have your music system pointed to (it's recommended to put explo under a subfolder)
@@ -13,15 +17,14 @@ services:
environment:
- TZ=UTC # Change this to the timezone set in ListenBrainz (default is UTC)
- - WEEKLY_EXPLORATION_SCHEDULE=15 00 * * 2 # Runs weekly, every Tuesday 15 minutes past midnight
- - WEEKLY_EXPLORATION_FLAGS= # Run weekly exploration with default settings
-
- # Uncomment _SCHEDULE and _FLAGS variables to enable fetching different playlist
- #- WEEKLY_JAMS_SCHEDULE=30 00 * * 1 # Runs weekly, every Monday 30 minutes past midnight
- #- WEEKLY_JAMS_FLAGS=--playlist=weekly-jams --download-mode=skip # Get tracks from weekly-jams, and only add tracks that are found locally to playlist
-
- #- DAILY_JAMS_SCHEDULE=15 01 * * * # Runs daily, every day 15 minutes past 1PM
- #- DAILY_JAMS_FLAGS=--playlist=daily-jams --download-mode=skip # Get tracks from daily-jams, and only add tracks that are found locally to playlist
+ # [Legacy] Schedules are managed through the web UI.
+ # These can still be set here for backwards compatibility β they will take precedence over the UI.
+ #- WEEKLY_EXPLORATION_SCHEDULE=15 00 * * 2
+ #- WEEKLY_EXPLORATION_FLAGS=
+ #- WEEKLY_JAMS_SCHEDULE=30 00 * * 1
+ #- WEEKLY_JAMS_FLAGS=--playlist=weekly-jams --download-mode=skip
+ #- DAILY_JAMS_SCHEDULE=15 01 * * *
+ #- DAILY_JAMS_FLAGS=--playlist=daily-jams --download-mode=skip
# Uncomment for testing (runs explo right after launcing the container)
#- EXECUTE_ON_START=false # Whether to run explo when starting the container (useful for testing)
diff --git a/docker/start.sh b/docker/start.sh
index a3b27d8..9b9e5fd 100644
--- a/docker/start.sh
+++ b/docker/start.sh
@@ -1,6 +1,33 @@
#!/bin/sh
+echo "[setup] Starting web UI..."
+# If user incorectly mounts the config path as a directory, we'll try to automatically append it to .env inside it instead of failing.
+WEB_CFG_PATH="${WEB_CFG_PATH:-/opt/explo/.env}"
+if [ -d "$WEB_CFG_PATH" ]; then
+ WEB_CFG_PATH="$WEB_CFG_PATH/.env"
+ echo "[setup] Config path is a directory, using $WEB_CFG_PATH"
+fi
+WEB_UI=true WEB_CFG_PATH="$WEB_CFG_PATH" WEB_ADDR="${WEB_ADDR:-:7288}" /opt/explo/explo &
+echo "[setup] Web UI available at http://localhost:${WEB_ADDR##*:}"
+
echo "[setup] Initializing cron jobs..."
+# Load *_SCHEDULE and *_FLAGS from .env if not already set in the environment.
+# This allows the web UI to configure schedules by writing to the .env file.
+_cfg="${WEB_CFG_PATH:-/opt/explo/.env}"
+if [ -f "$_cfg" ]; then
+ while IFS= read -r _line; do
+ case "$_line" in \#*|'') continue ;; esac
+ _key="${_line%%=*}"
+ case "$_key" in
+ *_SCHEDULE|*_FLAGS)
+ if [ -z "$(printenv "$_key" 2>/dev/null)" ]; then
+ export "$_key=${_line#*=}"
+ fi
+ ;;
+ esac
+ done < "$_cfg"
+fi
+
# $CRON_SHCEDULE was deprecated in v0.11.0, keeping this block for backwards compatibility
if [ -n "$CRON_SCHEDULE" ]; then
diff --git a/sample.env b/sample.env
index b4097fa..bf10362 100644
--- a/sample.env
+++ b/sample.env
@@ -127,4 +127,4 @@ YOUTUBE_API_KEY=
# Set the log level (DEBUG, INFO, WARN, ERROR) (default: INFO)
# LOG_LEVEL=INFO
# Set a custom HTTP timeout for music servers (in seconds) (default: 10)
-# CLIENT_HTTP_TIMEOUT=10
\ No newline at end of file
+# CLIENT_HTTP_TIMEOUT=10
diff --git a/src/client/plex.go b/src/client/plex.go
index e51f016..4a0b982 100644
--- a/src/client/plex.go
+++ b/src/client/plex.go
@@ -33,10 +33,10 @@ type Libraries struct {
Size int `json:"size"`
AllowSync bool `json:"allowSync"`
Title1 string `json:"title1"`
- Library []struct {
- Title string `json:"title"`
- Key string `json:"key"`
- Location []struct {
+ Library []struct {
+ Title string `json:"title"`
+ Key string `json:"key"`
+ Location []struct {
ID int `json:"id"`
Path string `json:"path"`
} `json:"Location"`
@@ -50,27 +50,27 @@ type PlexSearch struct {
SearchResult []struct {
Score float64 `json:"score"`
Metadata struct {
- LibrarySectionTitle string `json:"librarySectionTitle"`
- RatingKey string `json:"ratingKey"`
- Key string `json:"key"`
- Type string `json:"type"`
- Title string `json:"title"` // Track
- GrandparentTitle string `json:"grandparentTitle"` // Artist
- ParentTitle string `json:"parentTitle"` // Album
- OriginalTitle string `json:"originalTitle"`
- Summary string `json:"summary"`
- Duration int `json:"duration"`
- AddedAt int `json:"addedAt"`
- UpdatedAt int `json:"updatedAt"`
- Media []struct {
- ID int `json:"id"`
- Duration int `json:"duration"`
- Part []struct {
- ID int `json:"id"`
- Key string `json:"key"`
- Duration int `json:"duration"`
- File string `json:"file"`
- Size int `json:"size"`
+ LibrarySectionTitle string `json:"librarySectionTitle"`
+ RatingKey string `json:"ratingKey"`
+ Key string `json:"key"`
+ Type string `json:"type"`
+ Title string `json:"title"` // Track
+ GrandparentTitle string `json:"grandparentTitle"` // Artist
+ ParentTitle string `json:"parentTitle"` // Album
+ OriginalTitle string `json:"originalTitle"`
+ Summary string `json:"summary"`
+ Duration int `json:"duration"`
+ AddedAt int `json:"addedAt"`
+ UpdatedAt int `json:"updatedAt"`
+ Media []struct {
+ ID int `json:"id"`
+ Duration int `json:"duration"`
+ Part []struct {
+ ID int `json:"id"`
+ Key string `json:"key"`
+ Duration int `json:"duration"`
+ File string `json:"file"`
+ Size int `json:"size"`
} `json:"Part"`
AudioChannels int `json:"audioChannels"`
AudioCodec string `json:"audioCodec"`
@@ -81,7 +81,6 @@ type PlexSearch struct {
} `json:"MediaContainer"`
}
-
type PlexServer struct {
MediaContainer struct {
Size int `json:"size"`
@@ -112,15 +111,15 @@ type PlexPlaylist struct {
}
type Plex struct {
- machineID string
- LibraryID string
+ machineID string
+ LibraryID string
HttpClient *util.HttpClient
- Cfg config.ClientConfig
+ Cfg config.ClientConfig
}
func NewPlex(cfg config.ClientConfig, httpClient *util.HttpClient) *Plex {
return &Plex{
- Cfg: cfg,
+ Cfg: cfg,
HttpClient: httpClient}
}
@@ -135,7 +134,7 @@ func (c *Plex) AddHeader() error {
c.Cfg.Creds.Headers["X-Plex-Token"] = c.Cfg.Creds.APIKey
if err := c.getServer(); err != nil {
return err
- }
+ }
return nil
}
return fmt.Errorf("couldn't get API key")
@@ -154,7 +153,6 @@ func (c *Plex) GetAuth() error { // Get user token from plex
return fmt.Errorf("failed to marshal payload: %s", err.Error())
}
-
body, err := c.HttpClient.MakeRequest("POST", "https://plex.tv/users/sign_in.json", bytes.NewBuffer(payloadBytes), c.Cfg.Creds.Headers)
if err != nil {
return fmt.Errorf("%s", err.Error())
@@ -236,7 +234,7 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error {
slog.Warn("search request failed for '%s': %s", track.Title, err.Error())
continue
}
-
+
var searchResults PlexSearch
if err = util.ParseResp(body, &searchResults); err != nil {
slog.Warn("failed to parse response for '%s': %s", track.Title, err.Error())
@@ -277,7 +275,6 @@ func (c *Plex) SearchPlaylist() error {
return nil
}
-
func (c *Plex) CreatePlaylist(tracks []*models.Track) error {
params := fmt.Sprintf("/playlists?title=%s&type=audio&smart=0&uri=server://%s/com.plexapp.plugins.library/%s", c.Cfg.PlaylistName, c.machineID, c.LibraryID)
@@ -302,7 +299,7 @@ func (c *Plex) CreatePlaylist(tracks []*models.Track) error {
func (c *Plex) UpdatePlaylist() error {
params := fmt.Sprintf("/playlists/%s?summary=%s", c.Cfg.PlaylistID, url.QueryEscape(c.Cfg.PlaylistDescr))
- if _, err := c.HttpClient.MakeRequest("PUT",c.Cfg.URL+params, nil, c.Cfg.Creds.Headers); err != nil {
+ if _, err := c.HttpClient.MakeRequest("PUT", c.Cfg.URL+params, nil, c.Cfg.Creds.Headers); err != nil {
return err
}
return nil
@@ -358,7 +355,7 @@ func getPlexSong(track *models.Track, searchResults PlexSearch) (string, error)
media := md.Media[0]
pathMatch := strings.Contains(strings.ToLower(media.Part[0].File), strings.ToLower(track.File))
- durationMatch := util.Abs(media.Duration - track.Duration) < 10000 // duration within 10s
+ durationMatch := util.Abs(media.Duration-track.Duration) < 10000 // duration within 10s
if durationMatch && pathMatch {
slog.Debug(fmt.Sprintf("matched track via path: %s by %s", track.Title, track.Artist))
@@ -380,4 +377,4 @@ func (c *Plex) addtoPlaylist(tracks []*models.Track) {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/config/config.go b/src/config/config.go
index a88068b..73eda41 100644
--- a/src/config/config.go
+++ b/src/config/config.go
@@ -15,110 +15,109 @@ import (
)
type Config struct {
- DownloadCfg DownloadConfig
+ DownloadCfg DownloadConfig
DiscoveryCfg DiscoveryConfig
- ClientCfg ClientConfig
- NotifyCfg NotifyConfig
- Flags Flags
- PersistENV bool `env:"PERSIST" env-default:"true"`
- Persist bool
- System string `env:"EXPLO_SYSTEM"`
- Debug bool `env:"DEBUG" env-default:"false"`
- LogLevel string `env:"LOG_LEVEL" env-default:"INFO"`
+ ClientCfg ClientConfig
+ NotifyCfg NotifyConfig
+ Flags Flags
+ PersistENV bool `env:"PERSIST" env-default:"true"`
+ Persist bool
+ System string `env:"EXPLO_SYSTEM"`
+ Debug bool `env:"DEBUG" env-default:"false"`
+ LogLevel string `env:"LOG_LEVEL" env-default:"INFO"`
}
type Flags struct {
- CfgPath string
- Playlist string
+ CfgPath string
+ Playlist string
DownloadMode string
ExcludeLocal bool
- Persist bool
- PersistSet bool
+ Persist bool
+ PersistSet bool
}
type ClientConfig struct {
- ClientID string `env:"CLIENT_ID" env-default:"explo"`
- LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"`
- URL string `env:"SYSTEM_URL"`
- DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
- PlaylistDir string `env:"PLAYLIST_DIR"`
- PlaylistName string
+ ClientID string `env:"CLIENT_ID" env-default:"explo"`
+ LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"`
+ URL string `env:"SYSTEM_URL"`
+ DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
+ PlaylistDir string `env:"PLAYLIST_DIR"`
+ PlaylistName string
PlaylistNFormat string `env:"PLAYLISTNAME_FORMAT" env-default:"week"`
- PlaylistDescr string
- PlaylistID string
- Sleep int `env:"SLEEP" env-default:"2"`
- HTTPTimeout int `env:"CLIENT_HTTP_TIMEOUT" env-default:"10"`
- Creds Credentials
- AdminCreds AdminCredentials
- Subsonic SubsonicConfig
+ PlaylistDescr string
+ PlaylistID string
+ Sleep int `env:"SLEEP" env-default:"2"`
+ HTTPTimeout int `env:"CLIENT_HTTP_TIMEOUT" env-default:"10"`
+ Creds Credentials
+ AdminCreds AdminCredentials
+ Subsonic SubsonicConfig
}
type Credentials struct {
- APIKey string `env:"API_KEY"`
- User string `env:"SYSTEM_USERNAME"`
+ APIKey string `env:"API_KEY"`
+ User string `env:"SYSTEM_USERNAME"`
Password string `env:"SYSTEM_PASSWORD"`
- Headers map[string]string
- Token string
- Salt string
+ Headers map[string]string
+ Token string
+ Salt string
}
type AdminCredentials struct {
- User string `env:"ADMIN_SYSTEM_USERNAME"`
+ User string `env:"ADMIN_SYSTEM_USERNAME"`
Password string `env:"ADMIN_SYSTEM_PASSWORD"`
}
-
type SubsonicConfig struct {
- Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"`
- ID string `env:"CLIENT" env-default:"explo"`
- PublicPlaylist bool `env:"PUBLIC_PLAYLIST" env-default:"false"`
+ Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"`
+ ID string `env:"CLIENT" env-default:"explo"`
+ PublicPlaylist bool `env:"PUBLIC_PLAYLIST" env-default:"false"`
}
type DownloadConfig struct {
- DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
- Youtube Youtube
- YoutubeMusic YoutubeMusic
- Slskd Slskd
- ExcludeLocal bool
- KeepPermissions bool `env:"KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download
- RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format
- UseSubDir bool `env:"USE_SUBDIRECTORY" env-default:"true"`
- Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"`
- Services []string `env:"DOWNLOAD_SERVICES" env-default:"youtube"`
+ DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
+ Youtube Youtube
+ YoutubeMusic YoutubeMusic
+ Slskd Slskd
+ ExcludeLocal bool
+ KeepPermissions bool `env:"KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download
+ RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format
+ UseSubDir bool `env:"USE_SUBDIRECTORY" env-default:"true"`
+ Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"`
+ Services []string `env:"DOWNLOAD_SERVICES" env-default:"youtube"`
}
type Filters struct {
- Extensions []string `env:"EXTENSIONS" env-default:"flac,mp3"`
- MinBitDepth int `env:"MIN_BIT_DEPTH" env-default:"8"`
- MinBitRate int `env:"MIN_BITRATE" env-default:"256"`
- FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended,clean,acapella"`
+ Extensions []string `env:"EXTENSIONS" env-default:"flac,mp3"`
+ MinBitDepth int `env:"MIN_BIT_DEPTH" env-default:"8"`
+ MinBitRate int `env:"MIN_BITRATE" env-default:"256"`
+ FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended,clean,acapella"`
}
type Youtube struct {
- APIKey string `env:"YOUTUBE_API_KEY"`
- FfmpegPath string `env:"FFMPEG_PATH"`
- YtdlpPath string `env:"YTDLP_PATH"`
+ APIKey string `env:"YOUTUBE_API_KEY"`
+ FfmpegPath string `env:"FFMPEG_PATH"`
+ YtdlpPath string `env:"YTDLP_PATH"`
FileExtension string `env:"TRACK_EXTENSION" env-default:"opus"`
- CookiesPath string `env:"COOKIES_PATH" env-default:"./cookies.txt"`
- Filters Filters
+ CookiesPath string `env:"COOKIES_PATH" env-default:"./cookies.txt"`
+ Filters Filters
}
type YoutubeMusic struct {
FfmpegPath string `env:"FFMPEG_PATH"`
- YtdlpPath string `env:"YTDLP_PATH"`
- Filters Filters
+ YtdlpPath string `env:"YTDLP_PATH"`
+ Filters Filters
}
type Slskd struct {
- APIKey string `env:"SLSKD_API_KEY"`
- URL string `env:"SLSKD_URL"`
- Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track
- DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track
- SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"`
- MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir
- Timeout int `env:"SLSKD_TIMEOUT" env-default:"20"`
- Filters Filters
- MonitorConfig SlskdMon
+ APIKey string `env:"SLSKD_API_KEY"`
+ URL string `env:"SLSKD_URL"`
+ Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track
+ DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track
+ SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"`
+ MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir
+ Timeout int `env:"SLSKD_TIMEOUT" env-default:"20"`
+ Filters Filters
+ MonitorConfig SlskdMon
}
type SlskdMon struct {
@@ -127,37 +126,38 @@ type SlskdMon struct {
}
type DiscoveryConfig struct {
- Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"`
+ Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"`
Listenbrainz Listenbrainz
}
type Listenbrainz struct {
- Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"`
- User string `env:"LISTENBRAINZ_USER"`
+ Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"`
+ User string `env:"LISTENBRAINZ_USER"`
ImportPlaylist string
- SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"`
+ SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"`
}
type NotifyConfig struct {
- Matrix MatrixNotif
+ Matrix MatrixNotif
Discord DiscordNotif
- Http HttpNotif
+ Http HttpNotif
}
type MatrixNotif struct {
- UserID string `env:"MATRIX_USERID"`
- RoomID string `env:"MATRIX_ROOMID"`
- HomeServer string `env:"MATRIX_HOMESERVER_URL"`
+ UserID string `env:"MATRIX_USERID"`
+ RoomID string `env:"MATRIX_ROOMID"`
+ HomeServer string `env:"MATRIX_HOMESERVER_URL"`
AccessToken string `env:"MATRIX_ACCESSTOKEN"`
}
type DiscordNotif struct {
- BotToken string `env:"DISCORD_BOT_TOKEN"`
+ BotToken string `env:"DISCORD_BOT_TOKEN"`
ChannelIDs []string `env:"DISCORD_CHANNEL_ID"`
}
type HttpNotif struct {
ReceiverURLs []string `env:"HTTP_RECEIVER"`
}
+
func (cfg *Config) ReadEnv() {
// Try to read from .env file first
@@ -180,7 +180,8 @@ func (cfg *Config) ReadEnv() {
func (cfg *Config) CommonFixes() {
cfg.DownloadCfg.Youtube.FileExtension = strings.TrimPrefix(cfg.DownloadCfg.Youtube.FileExtension, ".")
- cfg.ClientCfg.URL = strings.TrimSuffix(cfg.ClientCfg.URL, "/")
+ cfg.ClientCfg.URL = fixBaseURL(cfg.ClientCfg.URL)
+ cfg.DownloadCfg.Slskd.URL = fixBaseURL(cfg.DownloadCfg.Slskd.URL)
cfg.NormalizeDir()
}
@@ -199,7 +200,18 @@ func fixDir(dir string) string {
return dir
}
-func (cfg *Config) HandleDeprecation() { //
+func fixBaseURL(rawURL string) string {
+ u := strings.TrimSpace(rawURL)
+ if u == "" {
+ return ""
+ }
+ if !strings.Contains(u, "://") {
+ u = "http://" + u
+ }
+ return strings.TrimRight(u, "/")
+}
+
+func (cfg *Config) HandleDeprecation() { //
if cfg.Debug {
slog.Warn("'DEBUG' variable is deprecated, please use LOG_LEVEL=DEBUG instead")
cfg.LogLevel = "DEBUG"
@@ -215,14 +227,13 @@ func (cfg *Config) HandleDeprecation() { //
func (cfg *Config) GenPlaylistName() { // Generate playlist name and description
-
cfg.ClientCfg.PlaylistName = getPlaylistName(cfg.Flags.Playlist, cfg.ClientCfg.PlaylistNFormat, cfg.Persist)
cfg.ClientCfg.PlaylistDescr = fmt.Sprintf(
"Created for %s by Explo, using ListenBrainz recommendations.",
cfg.DiscoveryCfg.Listenbrainz.User)
if cfg.DownloadCfg.UseSubDir {
- // add playlist name to downloadDir so all songs get downloaded to a single sub directory.
+ // add playlist name to downloadDir so all songs get downloaded to a single sub directory.
cfg.DownloadCfg.DownloadDir = filepath.Join(
cfg.DownloadCfg.DownloadDir,
cfg.ClientCfg.PlaylistName)
@@ -266,4 +277,4 @@ func getPlaylistName(playlistType, format string, persist bool) string {
year,
week,
)
-}
\ No newline at end of file
+}
diff --git a/src/config/flags.go b/src/config/flags.go
index 9874337..58cf0a5 100644
--- a/src/config/flags.go
+++ b/src/config/flags.go
@@ -1,67 +1,67 @@
-package config
-
-import (
- "slices"
- "fmt"
- "strings"
- flag "github.com/spf13/pflag"
-)
-
-var (
- validPlaylists = []string{"weekly-exploration", "weekly-jams", "daily-jams"}
- validDownloadMode = []string{"normal", "skip", "force"}
-)
-
-func (cfg *Config) GetFlags() error {
- var configPath string
- var playlist string
- var downloadMode string
- var excludeLocal bool
- var persist bool
- // Long flags
- flag.StringVarP(&configPath, "config", "c", ".env", "Path of the configuration file")
- flag.StringVarP(&playlist, "playlist", "p", "weekly-exploration", "Playlist where to get tracks. Supported: weekly-exploration, weekly-jams, daily-jams")
- flag.StringVarP(&downloadMode, "download-mode", "d", "normal", "Download mode: 'normal' (download only when track is not found locally), 'skip' (skip downloading, only use tracks already found locally), 'force' (always download, don't check for local tracks)")
- flag.BoolVarP(&excludeLocal, "exclude-local", "e", false, "Exclude locally found tracks from the imported playlist")
- flag.BoolVar(&persist, "persist", true, "Keep playlists between generations")
-
- flag.Parse()
- persistSet := flag.Lookup("persist").Changed
-
- // Validation for playlist
- if !contains(validPlaylists, playlist) {
- return fmt.Errorf("flag validation error: invalid playlist %s (must be one of: %s)",
- playlist, strings.Join(validPlaylists, ", "))
- }
-
- // Validation for download mode
- if !contains(validDownloadMode, downloadMode) {
- return fmt.Errorf("flag validation error: invalid download mode %s (must be one of: %s)",
- downloadMode, strings.Join(validDownloadMode, ", "))
- }
-
- cfg.Flags.CfgPath = configPath
- cfg.Flags.Playlist = playlist
- cfg.Flags.DownloadMode = downloadMode
- cfg.Flags.ExcludeLocal = excludeLocal
- cfg.Flags.Persist = persist
-
- // for deprecation purposes (can be removed at a later date)
- cfg.Flags.PersistSet = persistSet
-
- return nil
-}
-
-func (cfg *Config) MergeFlags() {
- cfg.DiscoveryCfg.Listenbrainz.ImportPlaylist = cfg.Flags.Playlist
- cfg.DownloadCfg.ExcludeLocal = cfg.Flags.ExcludeLocal
- if cfg.Flags.PersistSet {
- cfg.Persist = cfg.Flags.Persist
- } else {
- cfg.Persist = cfg.PersistENV
- }
-}
-
-func contains(valid []string, val string) bool {
- return slices.Contains(valid, val)
-}
\ No newline at end of file
+package config
+
+import (
+ "fmt"
+ flag "github.com/spf13/pflag"
+ "slices"
+ "strings"
+)
+
+var (
+ validPlaylists = []string{"weekly-exploration", "weekly-jams", "daily-jams"}
+ validDownloadMode = []string{"normal", "skip", "force"}
+)
+
+func (cfg *Config) GetFlags() error {
+ var configPath string
+ var playlist string
+ var downloadMode string
+ var excludeLocal bool
+ var persist bool
+ // Long flags
+ flag.StringVarP(&configPath, "config", "c", ".env", "Path of the configuration file")
+ flag.StringVarP(&playlist, "playlist", "p", "weekly-exploration", "Playlist where to get tracks. Supported: weekly-exploration, weekly-jams, daily-jams")
+ flag.StringVarP(&downloadMode, "download-mode", "d", "normal", "Download mode: 'normal' (download only when track is not found locally), 'skip' (skip downloading, only use tracks already found locally), 'force' (always download, don't check for local tracks)")
+ flag.BoolVarP(&excludeLocal, "exclude-local", "e", false, "Exclude locally found tracks from the imported playlist")
+ flag.BoolVar(&persist, "persist", true, "Keep playlists between generations")
+
+ flag.Parse()
+ persistSet := flag.Lookup("persist").Changed
+
+ // Validation for playlist
+ if !contains(validPlaylists, playlist) {
+ return fmt.Errorf("flag validation error: invalid playlist %s (must be one of: %s)",
+ playlist, strings.Join(validPlaylists, ", "))
+ }
+
+ // Validation for download mode
+ if !contains(validDownloadMode, downloadMode) {
+ return fmt.Errorf("flag validation error: invalid download mode %s (must be one of: %s)",
+ downloadMode, strings.Join(validDownloadMode, ", "))
+ }
+
+ cfg.Flags.CfgPath = configPath
+ cfg.Flags.Playlist = playlist
+ cfg.Flags.DownloadMode = downloadMode
+ cfg.Flags.ExcludeLocal = excludeLocal
+ cfg.Flags.Persist = persist
+
+ // for deprecation purposes (can be removed at a later date)
+ cfg.Flags.PersistSet = persistSet
+
+ return nil
+}
+
+func (cfg *Config) MergeFlags() {
+ cfg.DiscoveryCfg.Listenbrainz.ImportPlaylist = cfg.Flags.Playlist
+ cfg.DownloadCfg.ExcludeLocal = cfg.Flags.ExcludeLocal
+ if cfg.Flags.PersistSet {
+ cfg.Persist = cfg.Flags.Persist
+ } else {
+ cfg.Persist = cfg.PersistENV
+ }
+}
+
+func contains(valid []string, val string) bool {
+ return slices.Contains(valid, val)
+}
diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go
index 4a19d15..3151e8c 100644
--- a/src/discovery/listenbrainz.go
+++ b/src/discovery/listenbrainz.go
@@ -1,11 +1,11 @@
package discovery
import (
+ "errors"
"fmt"
+ "log/slog"
"strings"
"time"
- "log/slog"
- "errors"
cfg "explo/src/config"
"explo/src/models"
@@ -63,16 +63,16 @@ type CreatedFor struct {
PlaylistCount int `json:"playlist_count"`
Playlists []struct {
Playlist struct {
- Creator string `json:"creator"`
- Date time.Time `json:"date"`
- Extension struct {
+ Creator string `json:"creator"`
+ Date time.Time `json:"date"`
+ Extension struct {
HTTPSJspfPlaylist struct {
AdditionalMetadata struct {
AlgorithmMetadata struct {
SourcePatch string `json:"source_patch"`
} `json:"algorithm_metadata"`
} `json:"additional_metadata"`
- CreatedFor string `json:"created_for"`
+ CreatedFor string `json:"created_for"`
} `json:"https://musicbrainz.org/doc/jspf#playlist"`
} `json:"extension"`
Identifier string `json:"identifier"`
@@ -88,9 +88,9 @@ type Exploration struct {
Identifier string `json:"identifier"`
Title string `json:"title"`
Tracks []struct {
- Album string `json:"album"`
- Creator string `json:"creator"`
- Duration int `json:"duration"`
+ Album string `json:"album"`
+ Creator string `json:"creator"`
+ Duration int `json:"duration"`
Extension struct {
HTTPSJspfTrack struct {
AddedAt time.Time `json:"added_at"`
@@ -108,25 +108,24 @@ type Exploration struct {
} `json:"https://musicbrainz.org/doc/jspf#track"`
} `json:"extension"`
Identifier []string `json:"identifier"`
- Title string `json:"title"`
+ Title string `json:"title"`
} `json:"track"`
} `json:"playlist"`
}
type ListenBrainz struct {
HttpClient *util.HttpClient
- cfg cfg.Listenbrainz
- Separator string
+ cfg cfg.Listenbrainz
+ Separator string
}
-
func NewListenBrainz(cfg cfg.DiscoveryConfig, httpClient *util.HttpClient) *ListenBrainz {
return &ListenBrainz{
- cfg: cfg.Listenbrainz,
+ cfg: cfg.Listenbrainz,
HttpClient: httpClient,
}
}
-func (c *ListenBrainz) QueryTracks() ([]*models.Track, error) {
+func (c *ListenBrainz) QueryTracks() ([]*models.Track, error) {
var tracks []*models.Track
switch c.cfg.Discovery {
@@ -139,7 +138,7 @@ func (c *ListenBrainz) QueryTracks() ([]*models.Track, error) {
if err != nil {
return nil, err
}
-
+
default:
mbids, err := c.getAPIRecommendations(c.cfg.User)
if err != nil {
@@ -203,7 +202,7 @@ func (c *ListenBrainz) getTracks(mbids []string, singleArtist bool) ([]*models.T
mainArtist := recording.Artist.Name
recArtists := recording.Artist.Artists
-
+
if len(recArtists) > 1 {
mainArtist = recArtists[0].Name
if singleArtist {
@@ -219,16 +218,16 @@ func (c *ListenBrainz) getTracks(mbids []string, singleArtist bool) ([]*models.T
title = b.String()
artist = mainArtist
- }
+ }
}
tracks = append(tracks, &models.Track{
- Album: recording.Release.Name,
- Artist: artist,
- MainArtist: mainArtist,
- CleanTitle: rec.Name,
- Title: title,
- Duration: rec.Length,
+ Album: recording.Release.Name,
+ Artist: artist,
+ MainArtist: mainArtist,
+ CleanTitle: rec.Name,
+ Title: title,
+ Duration: rec.Length,
})
}
@@ -266,7 +265,7 @@ func (c *ListenBrainz) getImportPlaylist(user string) (string, error) {
if err != nil {
return "", fmt.Errorf("getImportPlaylist(): %s", err.Error())
}
-
+
if id, err := c.parseCreatedFor(playlists); err == nil {
return id, nil
}
@@ -289,7 +288,7 @@ func (c *ListenBrainz) parseCreatedFor(playlists CreatedFor) (string, error) {
} else {
_, currentWeek = now.ISOWeek()
}
-
+
for _, p := range playlists.Playlists {
meta := p.Playlist.Extension.HTTPSJspfPlaylist.AdditionalMetadata
@@ -354,7 +353,7 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) ([]*m
for _, a := range trackArtists[2:] {
b.WriteString(", ")
b.WriteString(a.ArtistCreditName)
-}
+ }
title = b.String()
artist = trackArtists[0].ArtistCreditName
}
@@ -377,9 +376,7 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) ([]*m
// Handle ListenBrainz API requests
func (c *ListenBrainz) lbRequest(path string) ([]byte, error) {
-
reqURL := fmt.Sprintf("https://api.listenbrainz.org/1/%s", path)
-
body, err := c.HttpClient.MakeRequest("GET", reqURL, nil, nil)
if err != nil {
return nil, fmt.Errorf("failed to make request to ListenBrainz API: %s", err)
@@ -388,6 +385,6 @@ func (c *ListenBrainz) lbRequest(path string) ([]byte, error) {
if len(body) == 0 {
return nil, fmt.Errorf("ListenBrainz API returned empty response for: %s", reqURL)
}
-
+
return body, nil
-}
\ No newline at end of file
+}
diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go
index b8393b4..c979265 100644
--- a/src/downloader/downloader.go
+++ b/src/downloader/downloader.go
@@ -26,6 +26,7 @@ type Downloader interface {
GetTrack(*models.Track) error
Monitor
}
+
// get download services from config and append them to DownloadClient
func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterLocal bool) (*DownloadClient, error) {
var downloader []Downloader
@@ -55,7 +56,7 @@ func (c *DownloadClient) StartDownload(tracks *[]*models.Track) {
slog.Error(err.Error())
return
}
-
+
for _, d := range c.Downloaders {
var g errgroup.Group
g.SetLimit(1)
@@ -132,7 +133,7 @@ func getFilename(title, artist string) string {
// Remove illegal characters for file naming
t := util.FilenameSafe(title)
- a :=util.FilenameSafe(artist)
+ a := util.FilenameSafe(artist)
// truncate long filename
runes := []rune(fmt.Sprintf("%s-%s", t, a))
@@ -150,14 +151,14 @@ func ContainsKeyword(track models.Track, contentTitle string, filterList []strin
content := strings.ToLower(contentTitle)
for _, keyword := range filterList {
- keyword = strings.ToLower(keyword)
- if strings.Contains(title, keyword) || strings.Contains(artist, keyword) {
- continue
- }
- if strings.Contains(content, keyword) {
- return true
+ keyword = strings.ToLower(keyword)
+ if strings.Contains(title, keyword) || strings.Contains(artist, keyword) {
+ continue
+ }
+ if strings.Contains(content, keyword) {
+ return true
+ }
}
-}
return false
}
@@ -191,7 +192,7 @@ func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track *
if err = os.MkdirAll(destDir, os.ModePerm); err != nil {
return fmt.Errorf("couldn't make download directory: %s", err.Error())
- }
+ }
dstFile := filepath.Join(destDir, track.File)
out, err := os.Create(dstFile)
@@ -258,4 +259,4 @@ func isDirEmpty(path string) (bool, error) {
return true, nil // no entries
}
return false, err
-}
\ No newline at end of file
+}
diff --git a/src/main/main.go b/src/main/main.go
index e034f4b..164a217 100644
--- a/src/main/main.go
+++ b/src/main/main.go
@@ -2,6 +2,7 @@ package main
import (
"explo/src/logging"
+ "explo/src/web"
"log"
"log/slog"
"os"
@@ -34,6 +35,23 @@ func setup(cfg *config.Config) {
}
func main() {
+ if os.Getenv("WEB_UI") == "true" {
+ cfgPath := os.Getenv("WEB_CFG_PATH")
+ if cfgPath == "" {
+ cfgPath = ".env"
+ }
+ exploPath, err := os.Executable()
+ if err != nil {
+ log.Fatal("could not determine executable path: ", err)
+ }
+ addr := os.Getenv("WEB_ADDR")
+ if addr == "" {
+ addr = ":7288"
+ }
+ srv := web.NewServer(cfgPath, exploPath)
+ log.Fatal(srv.Start(addr))
+ }
+
var cfg config.Config
if err := cfg.GetFlags(); err != nil {
log.Fatal(err)
@@ -44,19 +62,19 @@ func main() {
slog.Info("Starting Explo...")
httpClient := initHttpClient()
- client, err := client.NewClient(&cfg)
+ discovery := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient)
+ tracks, err := discovery.Discover()
if err != nil {
slog.Error(err.Error(), "notify", true)
os.Exit(1)
}
- discovery := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient)
- downloader, err := downloader.NewDownloader(&cfg.DownloadCfg, httpClient, cfg.Flags.ExcludeLocal)
+
+ client, err := client.NewClient(&cfg)
if err != nil {
slog.Error(err.Error(), "notify", true)
os.Exit(1)
}
-
- tracks, err := discovery.Discover()
+ downloader, err := downloader.NewDownloader(&cfg.DownloadCfg, httpClient, cfg.Flags.ExcludeLocal)
if err != nil {
slog.Error(err.Error(), "notify", true)
os.Exit(1)
@@ -89,4 +107,4 @@ func main() {
} else {
slog.Info("playlist created successfully", "system", cfg.System, "playlistName", cfg.ClientCfg.PlaylistName, "notify", true)
}
-}
\ No newline at end of file
+}
diff --git a/src/models/types.go b/src/models/types.go
index 91cf956..18ef47e 100644
--- a/src/models/types.go
+++ b/src/models/types.go
@@ -3,15 +3,15 @@ package models
// for structs used across the project
type Track struct {
- Album string
- ID string
- Artist string // All artists as returned by LB
- MainArtist string
+ Album string
+ ID string
+ Artist string // All artists as returned by LB
+ MainArtist string
MainArtistID string
- CleanTitle string // Title as returned by LB
- Title string // Title as built in listenbrainz.go
- File string // File name
- Size int // File size
- Present bool // is track present in the system or not
- Duration int // Track duration in milliseconds (not available for every track)
-}
\ No newline at end of file
+ CleanTitle string // Title as returned by LB
+ Title string // Title as built in listenbrainz.go
+ File string // File name
+ Size int // File size
+ Present bool // is track present in the system or not
+ Duration int // Track duration in milliseconds (not available for every track)
+}
diff --git a/src/web/frontend/index.html b/src/web/frontend/index.html
new file mode 100644
index 0000000..9fdd16a
--- /dev/null
+++ b/src/web/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Explo
+
+
+
+
+
+
diff --git a/src/web/frontend/package-lock.json b/src/web/frontend/package-lock.json
new file mode 100644
index 0000000..9ffa4ba
--- /dev/null
+++ b/src/web/frontend/package-lock.json
@@ -0,0 +1,2383 @@
+{
+ "name": "explo-frontend",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "explo-frontend",
+ "version": "0.0.1",
+ "dependencies": {
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "tailwindcss": "^4.0.0",
+ "vite": "^6.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
+ "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
+ "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
+ "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
+ "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
+ "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
+ "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
+ "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
+ "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
+ "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
+ "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
+ "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
+ "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
+ "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
+ "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
+ "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
+ "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
+ "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
+ "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
+ "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
+ "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
+ "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
+ "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
+ "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
+ "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.19.0",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.2.4"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
+ "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.2.4",
+ "@tailwindcss/oxide-darwin-arm64": "4.2.4",
+ "@tailwindcss/oxide-darwin-x64": "4.2.4",
+ "@tailwindcss/oxide-freebsd-x64": "4.2.4",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
+ "@tailwindcss/oxide-linux-x64-musl": "4.2.4",
+ "@tailwindcss/oxide-wasm32-wasi": "4.2.4",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
+ "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
+ "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
+ "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
+ "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
+ "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
+ "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
+ "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
+ "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
+ "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
+ "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.8.1",
+ "@emnapi/runtime": "^1.8.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.1",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
+ "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
+ "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz",
+ "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.2.4",
+ "@tailwindcss/oxide": "4.2.4",
+ "tailwindcss": "4.2.4"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.21",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz",
+ "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001790",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz",
+ "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.344",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
+ "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.21.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
+ "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.38",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
+ "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.5"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
+ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.2",
+ "@rollup/rollup-android-arm64": "4.60.2",
+ "@rollup/rollup-darwin-arm64": "4.60.2",
+ "@rollup/rollup-darwin-x64": "4.60.2",
+ "@rollup/rollup-freebsd-arm64": "4.60.2",
+ "@rollup/rollup-freebsd-x64": "4.60.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.2",
+ "@rollup/rollup-linux-arm64-musl": "4.60.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.2",
+ "@rollup/rollup-linux-loong64-musl": "4.60.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-musl": "4.60.2",
+ "@rollup/rollup-openbsd-x64": "4.60.2",
+ "@rollup/rollup-openharmony-arm64": "4.60.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.2",
+ "@rollup/rollup-win32-x64-gnu": "4.60.2",
+ "@rollup/rollup-win32-x64-msvc": "4.60.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
+ "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
+ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/src/web/frontend/package.json b/src/web/frontend/package.json
new file mode 100644
index 0000000..fd89b72
--- /dev/null
+++ b/src/web/frontend/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "explo-frontend",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "tailwindcss": "^4.0.0",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/src/web/frontend/src/App.jsx b/src/web/frontend/src/App.jsx
new file mode 100644
index 0000000..3312bc9
--- /dev/null
+++ b/src/web/frontend/src/App.jsx
@@ -0,0 +1,38 @@
+import { useState, useEffect } from 'react'
+import { fetchConfig } from './lib/api'
+import Wizard from './components/Wizard'
+import Settings from './components/Settings'
+
+export default function App() {
+ const [view, setView] = useState(null)
+ const [config, setConfig] = useState({})
+ const [envSources, setEnvSources] = useState({})
+
+ useEffect(() => {
+ fetchConfig().then(({ values, sources }) => {
+ setConfig(values)
+ setEnvSources(sources || {})
+ setView(values.LISTENBRAINZ_USER ? 'settings' : 'wizard')
+ })
+ }, [])
+
+ if (!view) return
+
+ if (view === 'wizard') {
+ return (
+ {
+ fetchConfig().then(({ values, sources }) => {
+ setConfig(values)
+ setEnvSources(sources || {})
+ setView('settings')
+ })
+ }}
+ />
+ )
+ }
+
+ return setView('wizard')} />
+}
diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx
new file mode 100644
index 0000000..62c78a8
--- /dev/null
+++ b/src/web/frontend/src/components/Settings.jsx
@@ -0,0 +1,518 @@
+/**
+ * Settings.jsx
+ *
+ * Main app view after initial setup. Three tabs: Home, Settings, Logs.
+ * Each section fetches its own data directly from the API β no prop-drilling.
+ *
+ * Sections:
+ * HomeSection β scheduled playlists, manual run, live output
+ * ConfigSection β raw .env editor, wizard re-run, reset
+ * LogsSection β full server log viewer
+ */
+
+import { useState, useEffect, useCallback, useRef } from 'react'
+import {
+ fetchConfig, fetchConfigRaw, saveConfig, resetConfig,
+ saveSchedule, startRun, stopRun, fetchRunStatus, fetchLogs,
+} from '../lib/api'
+import { parseSlogLine, cronToFields, highlightEnv } from '../lib/utils'
+import { Toggle } from './ui/Toggle'
+import { Button, SectionLabel, Panel, LogRow } from './ui/common'
+
+const tabBtnCls = active =>
+ `bg-transparent border-none border-b-2 pb-2 px-3.5 text-[13px] cursor-pointer transition-colors relative top-px
+ ${active ? 'text-white border-accent' : 'text-muted border-transparent hover:text-white'}`
+
+// ββ Home Tab ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Manages scheduled playlists, manual runs, and live run output.
+// Fetches its own config on mount to initialise schedule state and locked keys.
+
+// Streams live run output from /api/run/events
+function useSSE({ onLine, onDone }) {
+ const abortRef = useRef(null)
+
+ const connect = useCallback(async () => {
+ if (abortRef.current) abortRef.current.abort()
+ const controller = new AbortController()
+ abortRef.current = controller
+ try {
+ const res = await fetch('/api/run/events', { signal: controller.signal })
+ if (!res.ok) { onDone(null); return }
+ const reader = res.body.getReader()
+ const dec = new TextDecoder()
+ let buf = ''
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ buf += dec.decode(value, { stream: true })
+ const parts = buf.split('\n\n')
+ buf = parts.pop()
+ for (const part of parts) {
+ let ev = '', data = ''
+ for (const l of part.split('\n')) {
+ if (l.startsWith('event: ')) ev = l.slice(7).trim()
+ if (l.startsWith('data: ')) data = l.slice(6)
+ }
+ if (ev === 'done') { onDone(parseInt(data)); return }
+ else if (data) onLine(data)
+ }
+ }
+ } catch (e) {
+ if (e.name !== 'AbortError') onDone(null)
+ } finally {
+ if (abortRef.current === controller) abortRef.current = null
+ }
+ }, [onLine, onDone])
+
+ const disconnect = useCallback(() => {
+ abortRef.current?.abort()
+ abortRef.current = null
+ }, [])
+
+ return { connect, disconnect }
+}
+
+const PLAYLISTS = [
+ { value: 'weekly-exploration', name: 'Weekly Exploration' },
+ { value: 'weekly-jams', name: 'Weekly Jams' },
+ { value: 'daily-jams', name: 'Daily Jams' },
+]
+
+const SCHEDULE_KEYS = {
+ 'weekly-exploration': 'WEEKLY_EXPLORATION_SCHEDULE',
+ 'weekly-jams': 'WEEKLY_JAMS_SCHEDULE',
+ 'daily-jams': 'DAILY_JAMS_SCHEDULE',
+}
+
+const SCHEDULE_DAYS = [
+ { value: -1, label: 'Every day', summary: 'Daily' },
+ { value: 0, label: 'Sunday', summary: 'Every Sunday' },
+ { value: 1, label: 'Monday', summary: 'Every Monday' },
+ { value: 2, label: 'Tuesday', summary: 'Every Tuesday' },
+ { value: 3, label: 'Wednesday', summary: 'Every Wednesday' },
+ { value: 4, label: 'Thursday', summary: 'Every Thursday' },
+ { value: 5, label: 'Friday', summary: 'Every Friday' },
+ { value: 6, label: 'Saturday', summary: 'Every Saturday' },
+]
+
+const selectCls = 'bg-surface border border-ui-border text-white rounded-[6px] px-2.5 py-1.5 text-[13px] cursor-pointer outline-none focus:border-accent'
+
+function initSchedules(config) {
+ const defaults = {
+ 'weekly-exploration': { enabled: false, day: 2, hour: 0, minute: 15, editing: false },
+ 'weekly-jams': { enabled: false, day: 1, hour: 0, minute: 30, editing: false },
+ 'daily-jams': { enabled: false, day: -1, hour: 1, minute: 15, editing: false },
+ }
+ const out = {}
+ for (const [name, def] of Object.entries(defaults)) {
+ const cron = config[SCHEDULE_KEYS[name]]
+ out[name] = cron ? { enabled: true, editing: false, ...cronToFields(cron) } : def
+ }
+ return out
+}
+
+function HomeSection() {
+ const [schedules, setSchedules] = useState(null)
+ const [envSources, setEnvSources] = useState({})
+ const [scheduleSaveStatus, setScheduleSaveStatus] = useState({})
+
+ const [playlist, setPlaylist] = useState('weekly-exploration')
+ const [dlmode, setDlmode] = useState('normal')
+ const [noPersist, setNoPersist] = useState(false)
+ const [excludeLocal, setExcludeLocal] = useState(false)
+
+ const [running, setRunning] = useState(false)
+ const [status, setStatus] = useState('')
+ const [logEntries, setLogEntries] = useState([])
+ const [rawLog, setRawLog] = useState(false)
+ const [recentTracks, setRecentTracks] = useState([])
+ const logRef = useRef(null)
+
+ useEffect(() => {
+ fetchConfig().then(({ values, sources }) => {
+ setSchedules(initSchedules(values))
+ setEnvSources(sources || {})
+ })
+ fetchLogs().then(text => {
+ const entries = text.split('\n').filter(l => l.trim()).map(l => ({ raw: l, ...parseSlogLine(l) }))
+ setRecentTracks(entries.filter(e => e.track && e.level === 'INFO').reverse())
+ })
+ }, [])
+
+ const onLine = useCallback(data => {
+ setLogEntries(prev => [...prev, { raw: data, ...parseSlogLine(data) }])
+ requestAnimationFrame(() => {
+ if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
+ })
+ }, [])
+
+ const onDone = useCallback(code => {
+ setStatus(code === 0 ? 'done β' : code === null ? 'error' : `failed (exit ${code})`)
+ setRunning(false)
+ }, [])
+
+ const { connect, disconnect } = useSSE({ onLine, onDone })
+
+ useEffect(() => {
+ fetchRunStatus().then(s => {
+ if (s.running) {
+ setRunning(true)
+ setStatus('runningβ¦')
+ setLogEntries([])
+ connect()
+ }
+ })
+ return () => disconnect()
+ }, [connect, disconnect])
+
+ const isScheduleLocked = name => envSources[SCHEDULE_KEYS[name]] === 'env'
+
+ const scheduleTime = name => {
+ const s = schedules[name]
+ return `${String(s.hour).padStart(2, '0')}:${String(s.minute).padStart(2, '0')}`
+ }
+
+ const scheduleSummary = day => SCHEDULE_DAYS.find(d => d.value === day)?.summary || 'Daily'
+
+ const nextRunText = name => {
+ const s = schedules[name]
+ if (!s.enabled) return 'Disabled'
+ return `${scheduleSummary(s.day)} at ${String(s.hour).padStart(2, '0')}:${String(s.minute).padStart(2, '0')}`
+ }
+
+ const updateScheduleTime = (name, val) => {
+ const [h = '00', m = '00'] = val.split(':')
+ setSchedules(prev => ({
+ ...prev,
+ [name]: { ...prev[name], hour: parseInt(h) || 0, minute: parseInt(m) || 0 },
+ }))
+ }
+
+ const handleSaveSchedule = async name => {
+ if (isScheduleLocked(name)) return
+ const s = schedules[name]
+ try {
+ await saveSchedule(name, s.enabled, s.day, s.hour, s.minute)
+ setScheduleSaveStatus(prev => ({ ...prev, [name]: 'Saved.' }))
+ setTimeout(() => setScheduleSaveStatus(prev => ({ ...prev, [name]: '' })), 2000)
+ } catch {
+ setScheduleSaveStatus(prev => ({ ...prev, [name]: 'Error saving.' }))
+ }
+ }
+
+ const handleRun = async () => {
+ setRunning(true)
+ setLogEntries([])
+ setStatus('runningβ¦')
+ try {
+ await startRun(playlist, dlmode, !noPersist, excludeLocal)
+ connect()
+ } catch (e) {
+ if (e.conflict) { setStatus('already running'); setRunning(false); return }
+ setStatus('error')
+ setRunning(false)
+ }
+ }
+
+ const handleStop = async () => {
+ setStatus('stoppingβ¦')
+ try { await stopRun() }
+ catch { setStatus('error stopping run') }
+ }
+
+ if (!schedules) return null
+
+ return (
+
+ {/* Scheduled Playlists */}
+
+
Scheduled Playlists
+ {PLAYLISTS.map(p => {
+ const s = schedules[p.value]
+ const locked = isScheduleLocked(p.value)
+ return (
+
+
+
+ {
+ setSchedules(prev => ({ ...prev, [p.value]: { ...prev[p.value], enabled: v } }))
+ setTimeout(() => handleSaveSchedule(p.value), 0)
+ }}
+ disabled={locked}
+ />
+ {p.name}
+
+ setSchedules(prev => ({ ...prev, [p.value]: { ...prev[p.value], editing: !prev[p.value].editing } }))}
+ className="text-[12px] text-accent bg-surface border border-ui-border rounded-[6px] px-2.5 py-1 ml-auto cursor-pointer hover:border-accent hover:bg-[#242424] transition-colors disabled:opacity-45 disabled:cursor-not-allowed"
+ >
+ {nextRunText(p.value)}
+
+ {locked ? 'Set via Docker' : (scheduleSaveStatus[p.value] || '')}
+
+
+ {s.editing && s.enabled && !locked && (
+
+
+ Runs
+ setSchedules(prev => ({ ...prev, [p.value]: { ...prev[p.value], day: parseInt(e.target.value) } }))}
+ >
+ {SCHEDULE_DAYS.map(d => {d.label} )}
+
+ at
+ updateScheduleTime(p.value, e.target.value)}
+ className="bg-surface border border-ui-border text-white rounded-[6px] px-2 py-1.5 text-[13px] outline-none focus:border-accent"
+ />
+
+
+ { handleSaveSchedule(p.value); setSchedules(prev => ({ ...prev, [p.value]: { ...prev[p.value], editing: false } })) }}>
+ Save
+
+ setSchedules(prev => ({ ...prev, [p.value]: { ...prev[p.value], editing: false } }))}>
+ β
+
+
+
+ )}
+
+ )
+ })}
+
Schedule changes take effect after restarting the container.
+
+
+ {/* Manual Run */}
+
+
Manual run
+
+ Playlist
+ setPlaylist(e.target.value)}>
+ {PLAYLISTS.map(p => {p.name} )}
+
+ Download mode
+ setDlmode(e.target.value)}>
+ normal
+ skip
+ force
+
+
+ setNoPersist(e.target.checked)} /> no persist
+
+
+ setExcludeLocal(e.target.checked)} /> exclude local
+
+
+
+ βΆ Run
+ {running && (
+
+ β Stop
+
+ )}
+ {status}
+
+
+
+ {/* Recent Tracks */}
+ {recentTracks.length > 0 && (
+
+
Recent tracks
+
+ {recentTracks.slice(0, 50).map((e, i) => (
+
+ {e.time}
+ {e.track}
+
+ ))}
+
+
+ )}
+
+ {/* Output */}
+
+
+ Output
+
+ setRawLog(e.target.checked)} /> Raw
+
+
+
+ {logEntries.map((e, i) => (
+ rawLog
+ ? {e.raw}
+ :
+ ))}
+
+
+
+ )
+}
+
+// ββ Config Tab ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Raw .env file viewer/editor, plus wizard re-run and full reset actions.
+// Fetches its own raw config text from the API.
+
+function ConfigSection({ onWizard }) {
+ const [rawConfig, setRawConfig] = useState('')
+ const [editing, setEditing] = useState(false)
+ const [saveStatus, setSaveStatus] = useState('')
+
+ useEffect(() => {
+ fetchConfigRaw().then(text => setRawConfig(text))
+ }, [])
+
+ const handleSave = async () => {
+ try {
+ await saveConfig(rawConfig)
+ setEditing(false)
+ setSaveStatus('Saved.')
+ setTimeout(() => setSaveStatus(''), 2500)
+ } catch {
+ setSaveStatus('Error saving.')
+ }
+ }
+
+ const handleReset = async () => {
+ if (!confirm('Reset all settings? This will restart the container and take you back to setup.')) return
+ try {
+ await resetConfig()
+ const poll = async () => {
+ try { await fetch('/api/config'); location.reload() }
+ catch { setTimeout(poll, 1500) }
+ }
+ setTimeout(poll, 3000)
+ } catch (e) {
+ alert('Reset failed: ' + e.message)
+ }
+ }
+
+ return (
+
+
+
+
Config file
+ {!editing ? (
+
setEditing(true)}>Edit
+ ) : (
+
+ {saveStatus}
+ Save
+ { fetchConfigRaw().then(setRawConfig); setEditing(false) }}>Cancel
+
+ )}
+
+
+ {!editing ? (
+
+ ) : (
+
+
+
+
Setup
+
+
+ Re-run setup wizard β
+
+
+ Reset all settings
+
+
+
+
+ )
+}
+
+// ββ Logs Tab ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Displays the full server log file. Fetches its own log data from the API.
+
+function LogsSection() {
+ const [logFileEntries, setLogFileEntries] = useState([])
+
+ const loadLog = () => {
+ fetchLogs().then(text => {
+ setLogFileEntries(text.split('\n').filter(l => l.trim()).map(l => ({ raw: l, ...parseSlogLine(l) })))
+ })
+ }
+
+ useEffect(() => { loadLog() }, [])
+
+ return (
+
+
+ Log
+
+ Refresh
+
+
+
+ {logFileEntries.length === 0 ? (
+
No log output yet.
+ ) : (
+
+ {logFileEntries.map((e, i) => )}
+
+ )}
+
+ )
+}
+
+// ββ Settings ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Tab shell. Routes between Home, Settings, and Logs sections.
+
+export default function Settings({ onWizard }) {
+ const [activeTab, setActiveTab] = useState('run')
+
+ return (
+
+
+
+ Explo
+
+ setActiveTab('run')}>Home
+ setActiveTab('config')}>Settings
+ setActiveTab('logs')}>Logs
+
+
+
+ {activeTab === 'run' && }
+ {activeTab === 'config' && }
+ {activeTab === 'logs' && }
+
+
+ )
+}
diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx
new file mode 100644
index 0000000..d5ea2ed
--- /dev/null
+++ b/src/web/frontend/src/components/Wizard.jsx
@@ -0,0 +1,479 @@
+/**
+ * Wizard.jsx
+ *
+ * Three-step setup wizard. Owns all state and calls wizardStep1/2/3 to save
+ * each step. Step components (Step1, Step2, Step3) are defined in this file β
+ * they receive fields + setField from the Wizard component.
+ *
+ * Receives existing config/envSources from App to pre-populate fields.
+ */
+
+import { useState } from 'react'
+import { wizardStep1, wizardStep2, wizardStep3 } from '../lib/api'
+import { ToggleRow } from './ui/Toggle'
+import { DirInput } from './ui/DirInput'
+import { TextField } from './ui/common'
+
+const inputCls = 'w-full bg-surface border border-ui-border text-white rounded-[6px] px-3 py-2.5 text-[15px] outline-none focus:border-accent disabled:opacity-45 disabled:cursor-not-allowed transition-colors'
+
+const NextBtn = ({ onClick, disabled, saving, label = 'Next β' }) => (
+
+ {saving ? 'Savingβ¦' : label}
+
+)
+
+const BackBtn = ({ onClick }) => (
+
+ β Back
+
+)
+
+// ββ Step 1: Discovery βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Collects the ListenBrainz username, discovery mode (playlist vs API), and
+// which playlists the user wants to enable on a schedule.
+
+const PLAYLISTS = [
+ { value: 'weekly-exploration', name: 'Weekly Exploration', desc: '~50 tracks Β· refreshes every Tuesday' },
+ { value: 'weekly-jams', name: 'Weekly Jams', desc: '~25 tracks Β· refreshes every Monday' },
+ { value: 'daily-jams', name: 'Daily Jams', desc: '~25 tracks Β· refreshes daily' },
+]
+
+function Step1({ fields, setField, envSources, onNext, saving }) {
+ const { user, discoveryMode, checked } = fields
+ const isLocked = key => envSources[key] === 'env'
+ const valid = user.trim() !== '' && (discoveryMode !== 'playlist' || Object.values(checked).some(Boolean))
+
+ return (
+
+
Step 1 of 3 β Discovery
+
+ Explo uses your ListenBrainz listening history to find music recommendations.
+
+
+
+
Don't have an account?{' '}Sign up free. >}>
+ setField('user', e.target.value)}
+ disabled={isLocked('LISTENBRAINZ_USER')} />
+
+
+
+
Discovery mode
+
+ {[
+ { value: 'playlist', name: 'Playlist', desc: 'Pulls tracks from your ListenBrainz playlists on a schedule. Best once you have some listening history.' },
+ { value: 'api', name: 'API', desc: '~25 tracks generated on demand. Use this if your ListenBrainz account is new or testing your setup.' },
+ ].map(m => (
+ setField('discoveryMode', m.value)}
+ className={`text-left flex flex-col gap-[5px] px-4 py-3.5 bg-surface border rounded-[6px] cursor-pointer transition-colors
+ ${discoveryMode === m.value ? 'border-accent' : 'border-ui-border hover:border-[#404040]'}`}
+ >
+ {m.name}
+ {m.desc}
+
+ ))}
+
+
+
+ {discoveryMode === 'playlist' && (
+
+
Which playlists should run on a schedule?
+
+ {PLAYLISTS.map(p => (
+ setField('checked', { ...checked, [p.value]: v })}
+ name={p.name}
+ desc={p.desc}
+ />
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ )
+}
+
+// ββ Step 2: Media System ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Collects the media server type and its credentials. Fields shown/hidden
+// conditionally based on which system is selected.
+
+const SYSTEMS = [
+ { value: 'jellyfin', name: 'Jellyfin' },
+ { value: 'emby', name: 'Emby' },
+ { value: 'plex', name: 'Plex' },
+ { value: 'subsonic', name: 'Subsonic' },
+ { value: 'mpd', name: 'MPD' },
+]
+
+const API_KEY_SYSTEMS = ['jellyfin', 'emby', 'plex']
+
+function Step2({ fields, setField, envSources, onBack, onNext, saving }) {
+ const { system, systemUrl, apiKey, libraryName, systemUsername, systemPassword,
+ playlistDir, sleepMinutes, publicPlaylist } = fields
+ const isLocked = key => envSources[key] === 'env'
+
+ const urlPlaceholder = () => {
+ const ports = { jellyfin: '8096', emby: '8096', plex: '32400', subsonic: '4533' }
+ return `e.g. http://192.168.1.100:${ports[system] || '8096'}`
+ }
+
+ const valid = () => {
+ if (!system) return false
+ if (system === 'mpd') return playlistDir.trim() !== ''
+ if (!systemUrl) return false
+ if (API_KEY_SYSTEMS.includes(system) && !apiKey) return false
+ if (system === 'subsonic' && (!systemUsername || !systemPassword)) return false
+ return true
+ }
+
+ return (
+
+
Step 2 of 3 β Media System
+
+ Explo will add discovered tracks to your library and create playlists automatically. It needs access to your media server to do this.
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// ββ Step 3: Downloader ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Collects download service selection (YouTube, Slskd) and their respective
+// credentials, download directory, and file format preferences.
+
+function Step3({ fields, setField, envSources, onBack, onFinish, saving }) {
+ const { downloadDir, useSubdirectory, migrateDownloads, dlServices,
+ youtubeApiKey, trackExtension, filterList, slskdUrl, slskdApiKey } = fields
+ const isLocked = key => envSources[key] === 'env'
+ const showDownloadDir = dlServices.youtube || (dlServices.slskd && migrateDownloads)
+
+ const valid = () => {
+ if (!Object.values(dlServices).some(Boolean)) return false
+ if (showDownloadDir && !downloadDir.trim()) return false
+ if (dlServices.slskd && (!slskdUrl.trim() || !slskdApiKey.trim())) return false
+ return true
+ }
+
+ return (
+
+
Step 3 of 3 β Downloader
+
+ Explo downloads tracks using one or both services. Enable what you have access to β if both are enabled, YouTube is tried first.
+
+
+
+
+
Which download services should Explo use?
+
+ setField('dlServices', { ...dlServices, youtube: v })}
+ name="YouTube"
+ desc="Downloads via yt-dlp Β· falls back to ytmusicapi if no API key is set"
+ />
+ setField('dlServices', { ...dlServices, slskd: v })}
+ name="Slskd"
+ desc="Downloads from the Soulseek P2P network Β· requires a running Slskd instance"
+ />
+
+
+
+ {dlServices.youtube && (
+ <>
+
YouTube API Key (optional) >}
+ hint={<>If set, uses the official YouTube Data API. Otherwise falls back to ytmusicapi .{' '}
+ Get an API key. >}>
+ setField('youtubeApiKey', e.target.value)}
+ autoComplete="off" spellCheck={false} placeholder="AIzaβ¦" disabled={isLocked('YOUTUBE_API_KEY')} />
+
+
File format yt-dlp converts to. Default is opus β use mp3 for broader device compatibility.>}>
+ setField('trackExtension', e.target.value)}
+ placeholder="opus" autoComplete="off" spellCheck={false} disabled={isLocked('TRACK_EXTENSION')} />
+
+
+ setField('filterList', e.target.value)}
+ placeholder="live,remix,instrumental,extended,clean,acapella" autoComplete="off" spellCheck={false} disabled={isLocked('FILTER_LIST')} />
+
+ >
+ )}
+
+ {dlServices.slskd && (
+ <>
+
+ setField('slskdUrl', e.target.value)}
+ placeholder="e.g. http://192.168.1.100:5030" disabled={isLocked('SLSKD_URL')} />
+
+
+ setField('slskdApiKey', e.target.value)}
+ autoComplete="off" spellCheck={false} disabled={isLocked('SLSKD_API_KEY')} />
+
+
setField('migrateDownloads', v)}
+ disabled={isLocked('MIGRATE_DOWNLOADS')}
+ desc="Move completed downloads to a separate directory"
+ />
+ >
+ )}
+
+ {showDownloadDir && (
+ <>
+
+ setField('downloadDir', v)} disabled={isLocked('DOWNLOAD_DIR')}
+ placeholder="e.g. /data/music/" />
+
+ setField('useSubdirectory', v)}
+ disabled={isLocked('USE_SUBDIRECTORY')}
+ name="Use playlist subfolders"
+ desc="Create a subfolder per playlist inside the download directory"
+ />
+ >
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+// ββ Wizard ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Owns all wizard state and calls wizardStep1/2/3 APIs to save each step.
+// Receives existing config/envSources from App to pre-populate fields.
+
+export default function Wizard({ config, envSources, onComplete }) {
+ const [step, setStep] = useState(1)
+ const [saving, setSaving] = useState(false)
+
+ const [fields, setFields] = useState(() => {
+ const s = (config.DOWNLOAD_SERVICES || '').split(',')
+ return {
+ // Step 1
+ user: config.LISTENBRAINZ_USER || '',
+ discoveryMode: config.LISTENBRAINZ_DISCOVERY || 'playlist',
+ checked: {
+ 'weekly-exploration': !!config.WEEKLY_EXPLORATION_SCHEDULE,
+ 'weekly-jams': !!config.WEEKLY_JAMS_SCHEDULE,
+ 'daily-jams': !!config.DAILY_JAMS_SCHEDULE,
+ },
+ // Step 2
+ system: config.EXPLO_SYSTEM || '',
+ systemUrl: config.SYSTEM_URL || '',
+ apiKey: config.API_KEY || '',
+ libraryName: config.LIBRARY_NAME || '',
+ systemUsername: config.SYSTEM_USERNAME || '',
+ systemPassword: config.SYSTEM_PASSWORD || '',
+ playlistDir: config.PLAYLIST_DIR || '',
+ sleepMinutes: config.SLEEP || '',
+ publicPlaylist: config.PUBLIC_PLAYLIST === 'true',
+ // Step 3
+ downloadDir: config.DOWNLOAD_DIR || '',
+ useSubdirectory: config.USE_SUBDIRECTORY !== 'false',
+ migrateDownloads: config.MIGRATE_DOWNLOADS === 'true',
+ dlServices: { youtube: s.includes('youtube'), slskd: s.includes('slskd') },
+ youtubeApiKey: config.YOUTUBE_API_KEY || '',
+ trackExtension: config.TRACK_EXTENSION || '',
+ filterList: config.FILTER_LIST || '',
+ slskdUrl: config.SLSKD_URL || '',
+ slskdApiKey: config.SLSKD_API_KEY || '',
+ }
+ })
+
+ const setField = (key, val) => setFields(prev => ({ ...prev, [key]: val }))
+
+ const lockedKeys = Object.entries(envSources)
+ .filter(([k, s]) => s === 'env' && !k.endsWith('_SCHEDULE') && !k.endsWith('_FLAGS'))
+ .map(([k]) => k)
+
+ async function handleStep1() {
+ setSaving(true)
+ try {
+ const playlists = Object.entries(fields.checked).filter(([, v]) => v).map(([k]) => k)
+ await wizardStep1(fields.user.trim(), playlists, fields.discoveryMode)
+ setStep(2)
+ } catch (e) {
+ alert('Error saving: ' + e.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ async function handleStep2() {
+ setSaving(true)
+ try {
+ await wizardStep2({
+ system: fields.system, url: fields.systemUrl, api_key: fields.apiKey,
+ library_name: fields.libraryName, username: fields.systemUsername,
+ password: fields.systemPassword, playlist_dir: fields.playlistDir,
+ sleep: fields.sleepMinutes, public_playlist: fields.publicPlaylist,
+ })
+ setStep(3)
+ } catch (e) {
+ alert('Error saving: ' + e.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ async function handleStep3() {
+ setSaving(true)
+ try {
+ const services = Object.entries(fields.dlServices).filter(([, v]) => v).map(([k]) => k)
+ await wizardStep3({
+ download_dir: fields.downloadDir, use_subdirectory: fields.useSubdirectory,
+ migrate_downloads: fields.migrateDownloads, download_services: services,
+ youtube_api_key: fields.youtubeApiKey, track_extension: fields.trackExtension,
+ filter_list: fields.filterList, slskd_url: fields.slskdUrl, slskd_api_key: fields.slskdApiKey,
+ })
+ onComplete()
+ } catch (e) {
+ alert('Error saving: ' + e.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
Explo
+
+ {lockedKeys.length > 0 && (
+
+ You've set the following in your Docker environment, so they can't be changed here:{' '}
+ {lockedKeys.join(', ')}
+
+ )}
+
+ {step === 1 && (
+
+ )}
+ {step === 2 && (
+
setStep(1)} onNext={handleStep2} saving={saving}
+ />
+ )}
+ {step === 3 && (
+ setStep(2)} onFinish={handleStep3} saving={saving}
+ />
+ )}
+
+
+ )
+}
diff --git a/src/web/frontend/src/components/ui/DirInput.jsx b/src/web/frontend/src/components/ui/DirInput.jsx
new file mode 100644
index 0000000..e3593fe
--- /dev/null
+++ b/src/web/frontend/src/components/ui/DirInput.jsx
@@ -0,0 +1,46 @@
+import { useState } from 'react'
+import { fetchBrowse } from '../../lib/api'
+
+export function DirInput({ value, onChange, disabled, placeholder }) {
+ const [show, setShow] = useState(false)
+ const [suggestions, setSuggestions] = useState([])
+
+ async function browse(input) {
+ const path = input.endsWith('/') ? input : (input.includes('/') ? input.slice(0, input.lastIndexOf('/') + 1) : '/')
+ try {
+ const all = await fetchBrowse(path || '/')
+ setSuggestions(all.filter(d => d.startsWith(input)))
+ } catch {
+ setSuggestions([])
+ }
+ }
+
+ return (
+
+
{ onChange(e.target.value); browse(e.target.value) }}
+ onFocus={() => { browse(value); setShow(true) }}
+ onBlur={() => setTimeout(() => setShow(false), 150)}
+ className="w-full bg-surface border border-ui-border text-white rounded-[6px] px-3 py-2.5 text-[15px] outline-none focus:border-accent disabled:opacity-45 disabled:cursor-not-allowed transition-colors"
+ />
+ {show && suggestions.length > 0 && (
+
+ {suggestions.map(d => (
+ { e.preventDefault(); onChange(d + '/'); browse(d + '/') }}
+ className="px-3 py-2 text-[13px] text-muted cursor-pointer font-mono hover:bg-[#242424] hover:text-white transition-colors"
+ >
+ {d}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/web/frontend/src/components/ui/Toggle.jsx b/src/web/frontend/src/components/ui/Toggle.jsx
new file mode 100644
index 0000000..2252d74
--- /dev/null
+++ b/src/web/frontend/src/components/ui/Toggle.jsx
@@ -0,0 +1,33 @@
+export function Toggle({ checked, onChange, disabled }) {
+ return (
+ !disabled && onChange(!checked)}
+ >
+
+
+ )
+}
+
+export function ToggleRow({ checked, onChange, disabled, name, desc, children }) {
+ return (
+
+
+ {name && {name} }
+ {desc && {desc} }
+ {children}
+
+
+
+ )
+}
diff --git a/src/web/frontend/src/components/ui/common.jsx b/src/web/frontend/src/components/ui/common.jsx
new file mode 100644
index 0000000..bfa9149
--- /dev/null
+++ b/src/web/frontend/src/components/ui/common.jsx
@@ -0,0 +1,67 @@
+import { forwardRef } from 'react'
+
+// Surface-style button with hover-accent border.
+// Accepts className to override padding/size/color for variants.
+export function Button({ children, className = '', ...props }) {
+ return (
+
+ {children}
+
+ )
+}
+
+// Small-caps section heading. Defaults to mb-3.5; pass className="" to suppress.
+export function SectionLabel({ children, className = 'mb-3.5' }) {
+ return (
+
+ {children}
+
+ )
+}
+
+// Label + input(s) + optional hint wrapper for form fields.
+// Pass labelFor to wire the label's htmlFor. hint accepts ReactNode.
+export function TextField({ label, labelFor, hint, children }) {
+ return (
+
+ {label}
+ {children}
+ {hint && {hint} }
+
+ )
+}
+
+// Scrollable well container for logs, track lists, etc.
+// Accepts ref via forwardRef (needed for auto-scroll).
+export const Panel = forwardRef(({ children, className = '', ...props }, ref) => (
+
+ {children}
+
+))
+Panel.displayName = 'Panel'
+
+// A single structured log entry row (structured view, not raw).
+export function LogRow({ entry }) {
+ return (
+
+ {entry.time}
+ {entry.level !== 'INFO' && (
+
+ {entry.level}
+
+ )}
+ {entry.msg}
+ {entry.track && {entry.track} }
+ {entry.system && {entry.system} }
+
+ )
+}
diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css
new file mode 100644
index 0000000..2ffef7b
--- /dev/null
+++ b/src/web/frontend/src/index.css
@@ -0,0 +1,22 @@
+@import "tailwindcss";
+
+@theme {
+ --color-bg: #121212;
+ --color-surface: #1a1a1a;
+ --color-ui-border: #282828;
+ --color-muted: #b3b3b3;
+ --color-accent: #1ed760;
+ --color-well: #0d0d0d;
+ --color-danger: #e05555;
+}
+
+@layer base {
+ body {
+ background-color: var(--color-bg);
+ color: white;
+ }
+}
+
+/* Syntax highlight classes used in the config editor */
+.env-comment, .env-unset, .env-eq { color: var(--color-muted); }
+.env-key, .env-val { color: white; }
diff --git a/src/web/frontend/src/lib/api.js b/src/web/frontend/src/lib/api.js
new file mode 100644
index 0000000..4f44416
--- /dev/null
+++ b/src/web/frontend/src/lib/api.js
@@ -0,0 +1,91 @@
+export async function fetchConfig() {
+ const res = await fetch('/api/config')
+ return res.json()
+}
+
+export async function fetchConfigRaw() {
+ const res = await fetch('/api/config/raw')
+ return res.text()
+}
+
+export async function saveConfig(text) {
+ const res = await fetch('/api/config', {
+ method: 'POST',
+ headers: { 'Content-Type': 'text/plain' },
+ body: text,
+ })
+ if (!res.ok) throw new Error(await res.text())
+}
+
+export async function resetConfig() {
+ const res = await fetch('/api/config/reset', { method: 'POST' })
+ if (!res.ok) throw new Error(await res.text())
+}
+
+export async function saveSchedule(name, enabled, day, hour, minute) {
+ const res = await fetch('/api/config/schedules', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, enabled, day, hour, minute }),
+ })
+ if (!res.ok) throw new Error(await res.text())
+}
+
+export async function wizardStep1(user, playlists, discovery_mode) {
+ const res = await fetch('/api/wizard/step1', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ user, playlists, discovery_mode }),
+ })
+ if (!res.ok) throw new Error(await res.text())
+}
+
+export async function wizardStep2(body) {
+ const res = await fetch('/api/wizard/step2', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ if (!res.ok) throw new Error(await res.text())
+}
+
+export async function wizardStep3(body) {
+ const res = await fetch('/api/wizard/step3', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ if (!res.ok) throw new Error(await res.text())
+}
+
+export async function fetchBrowse(path) {
+ const res = await fetch('/api/browse?path=' + encodeURIComponent(path || '/'))
+ return res.json()
+}
+
+export async function startRun(playlist, download_mode, persist, exclude_local) {
+ const form = new FormData()
+ form.set('playlist', playlist)
+ form.set('download_mode', download_mode)
+ form.set('persist', persist ? 'true' : 'false')
+ form.set('exclude_local', exclude_local ? 'true' : 'false')
+ const res = await fetch('/api/run', { method: 'POST', body: form })
+ if (res.status === 409) throw Object.assign(new Error('already running'), { conflict: true })
+ if (!res.ok) throw new Error(await res.text())
+ return res.json()
+}
+
+export async function stopRun() {
+ const res = await fetch('/api/run/stop', { method: 'POST' })
+ if (!res.ok) throw new Error(await res.text())
+}
+
+export async function fetchRunStatus() {
+ const res = await fetch('/api/run/status')
+ return res.json()
+}
+
+export async function fetchLogs() {
+ const res = await fetch('/api/logs')
+ return res.text()
+}
diff --git a/src/web/frontend/src/lib/utils.js b/src/web/frontend/src/lib/utils.js
new file mode 100644
index 0000000..1f15110
--- /dev/null
+++ b/src/web/frontend/src/lib/utils.js
@@ -0,0 +1,45 @@
+function escHtml(s) {
+ return s.replace(/&/g, '&').replace(//g, '>')
+}
+
+export function highlightEnv(text) {
+ return text.split('\n').map(line => {
+ const trimmed = line.trim()
+ if (!trimmed) return ''
+ if (trimmed.startsWith('#')) return ``
+ const eq = line.indexOf('=')
+ if (eq >= 0) {
+ const key = line.slice(0, eq)
+ const val = line.slice(eq + 1).trim()
+ if (!val) return `${escHtml(line)} `
+ return `${escHtml(key)} = ${escHtml(line.slice(eq + 1))} `
+ }
+ return escHtml(line)
+ }).join('\n')
+}
+
+export function parseSlogLine(line) {
+ const kv = {}
+ const re = /(\w+)=("(?:[^"\\]|\\.)*"|[^ ]+)/g
+ let m
+ while ((m = re.exec(line)) !== null) {
+ const [, k, v] = m
+ kv[k] = v.startsWith('"') ? v.slice(1, -1).replace(/\\"/g, '"') : v
+ }
+ if (!kv.msg && !kv.time) return { time: '', level: 'INFO', msg: line }
+ let time = ''
+ if (kv.time) {
+ try { time = new Date(kv.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) }
+ catch { time = kv.time }
+ }
+ return { time, level: (kv.level || 'INFO').toUpperCase(), msg: kv.msg || line, track: kv.track || '', system: kv.system || '' }
+}
+
+export function cronToFields(cron) {
+ const parts = cron.trim().split(/\s+/)
+ return {
+ minute: parseInt(parts[0]) || 0,
+ hour: parseInt(parts[1]) || 0,
+ day: parts[4] === '*' ? -1 : (parseInt(parts[4]) || 0),
+ }
+}
diff --git a/src/web/frontend/src/main.jsx b/src/web/frontend/src/main.jsx
new file mode 100644
index 0000000..f55f4f7
--- /dev/null
+++ b/src/web/frontend/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App'
+
+createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/src/web/frontend/vite.config.js b/src/web/frontend/vite.config.js
new file mode 100644
index 0000000..4f5f5b8
--- /dev/null
+++ b/src/web/frontend/vite.config.js
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ server: {
+ proxy: {
+ '/api': 'http://localhost:7288',
+ },
+ },
+ build: {
+ outDir: '../dist',
+ emptyOutDir: true,
+ },
+})
diff --git a/src/web/sample.env b/src/web/sample.env
new file mode 100644
index 0000000..bf10362
--- /dev/null
+++ b/src/web/sample.env
@@ -0,0 +1,130 @@
+# === Discovery Config ===
+
+# Service which recommends songs (only 'listenbrainz' is supported)
+# DISCOVERY_SERVICE=listenbrainz
+# Your ListenBrainz username
+LISTENBRAINZ_USER=
+# 'playlist' to fetch weekly playlist (50 songs), 'api' for fewer songs (good for testing) (default: playlist)
+# LISTENBRAINZ_DISCOVERY=playlist
+
+# === Music System Configuration ===
+
+# Music system you use: emby, jellyfin, mpd, plex or subsonic
+EXPLO_SYSTEM=
+# Address of your media system (e.g. http://127.0.0.1:4533)
+SYSTEM_URL=
+# Username with access to system (required for all except mpd)
+SYSTEM_USERNAME=
+# Password for the user (required for subsonic, recommended for plex)
+SYSTEM_PASSWORD=
+# Optional admin username for systems like Navidrome/Subsonic (used only for triggering library scans)
+# ADMIN_SYSTEM_USERNAME=
+# Optional admin password for systems like Navidrome/Subsonic (used only for triggering library scans)
+# ADMIN_SYSTEM_PASSWORD=
+# API Key from your media system (required for emby and jellyfin, optional for plex)
+API_KEY=
+# Name of the music library in your system (emby, jellyfin, plex)
+LIBRARY_NAME=
+# Mark playlist as public (subsonic)
+# PUBLIC_PLAYLIST=false
+
+# === Downloader Configuration ===
+
+# Directory to store downloaded tracks. It's recommended to make a separate directory (under the music library) for Explo
+# PS! This is only needed when running the binary version, in docker it's set through volume mapping
+# DOWNLOAD_DIR=/path/to/musiclibrary/explo/
+# Download/move tracks to a subdirectory named after the playlist
+# USE_SUBDIRECTORY=true
+# Keep original file permissions when moving files (set to false on Synology devices)
+# KEEP_PERMISSIONS=true
+# Comma-separated list (no spaces) of download services, in priority order (default: youtube)
+# DOWNLOAD_SERVICES=youtube
+
+# Directory for writing .m3u playlists (required only for MPD)
+# PLAYLIST_DIR=/path/to/playlist/folder/
+
+# === YouTube Configuration ===
+
+# YouTube Data API key (required if using youtube)
+YOUTUBE_API_KEY=
+# Custom file extension for tracks (e.g mp3) (default: opus)
+# TRACK_EXTENSION=opus
+# Custom path to ffmpeg binary (default: defined in $PATH)
+# FFMPEG_PATH=
+# Custom path to yt-dlp binary (default: defined in $PATH)
+# YTDLP_PATH=
+# Path to (optional) cookies file (default: ./cookies.txt) (in docker this is set through volume mapping)
+# COOKIES_PATH=./cookies.txt
+# Comma-separated (without spaces) keywords to exclude from YouTube results (default: live,remix,instrumental,extended,clean,acapella)
+# FILTER_LIST=live,remix,instrumental,extended
+
+# === Slskd Configuration ===
+
+# Slskd instance address (requires running instance)
+# SLSKD_URL=
+# Slskd API key
+# SLSKD_API_KEY=
+# Whether to move downloads under the DOWNLOAD_DIR or not (default: false)
+# MIGRATE_DOWNLOADS=false
+# Rename migrated track in {artist}-{title} format
+# RENAME_TRACK=false
+# Directory where slskd downloads tracks (default: /slskd/)
+# PS! This is only needed on the binary version, in docker it's set through volume mapping
+# SLSKD_DIR=/slskd/
+# Number of times to check search status before skipping the track (default: 5)
+# SLSKD_RETRY=5
+# Number of download attempts for a track (default: 3)
+# SLSKD_DL_ATTEMPTS=3
+
+## Slskd Filtering
+
+# Comma-separated (without spaces) file extensions to download from (default: flac,mp3)
+# EXTENSIONS=flac,mp3
+# Minimal Bit Depth (default: 8)
+# MIN_BIT_DEPTH=8
+# Minimal Bitrate (default: 256)
+# MIN_BITRATE=256
+# Comma-separated (without spaces) keywords to avoid, when filtering slskd results (default: live,remix,instrumental,extended,clean,acapella)
+# FILTER_LIST=live,remix,instrumental,extended,clean,acapella
+
+# === Metadata / Formatting ===
+
+# Set to true to merge featured artists into title (recommended), false appends them to artist field (default: true)
+# SINGLE_ARTIST=true
+# Playlist name format: week (Weekly-Exploration-2026-Week5) or date (Weekly-Exploration-2026-01-31)
+# PLAYLISTNAME_FORMAT=week
+
+# === Notifications ===
+
+## Discord
+
+# Application's (bot) token
+# DISCORD_BOT_TOKEN=
+# Channel ID where to send notifications (supports multiple IDs, use comma (without spaces) to separate them)
+# DISCORD_CHANNEL_ID=
+
+## HTTP
+
+# HTTP URL to send POST requests to (supports multiple URLs, use comma (without spaces) to separate them)
+# HTTP_RECEIVER=
+
+## Matrix
+
+# User ID for Matrix
+# MATRIX_USERID=
+# Room ID to send notifications in
+# MATRIX_ROOMID=
+# Homeserver URL that the room is created in
+# MATRIX_HOMESERVER_URL=
+# Users Access token
+# MATRIX_ACCESSTOKEN=
+
+
+# === Misc ===
+
+# Minutes to sleep between library scans (default: 2)
+# SLEEP=2
+# Set the log level (DEBUG, INFO, WARN, ERROR) (default: INFO)
+# LOG_LEVEL=INFO
+# Set a custom HTTP timeout for music servers (in seconds) (default: 10)
+# CLIENT_HTTP_TIMEOUT=10
diff --git a/src/web/server.go b/src/web/server.go
new file mode 100644
index 0000000..25af328
--- /dev/null
+++ b/src/web/server.go
@@ -0,0 +1,980 @@
+package web
+
+import (
+ "bufio"
+ "context"
+ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+)
+
+//go:embed dist
+var distFiles embed.FS
+
+//go:embed sample.env
+var sampleEnv []byte
+
+// 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 {
+ Key string `json:"key"`
+ Label string `json:"label"`
+ Type string `json:"type"` // text | password | url | select
+ Section string `json:"section"` // discovery | system | downloader
+ Placeholder string `json:"placeholder,omitempty"`
+ Hint string `json:"hint,omitempty"`
+ Required bool `json:"required,omitempty"`
+ Options []Option `json:"options,omitempty"` // for type=select
+ VisibleWhen *Condition `json:"visibleWhen,omitempty"` // hide field when condition is false
+ RequiredWhen *Condition `json:"requiredWhen,omitempty"` // conditionally required
+}
+
+var netSystems = []string{"jellyfin", "emby", "plex", "subsonic"}
+var apiKeySystems = []string{"jellyfin", "emby", "plex"}
+
+// allConfigKeys is the complete set of env keys the web UI reads and writes.
+var allConfigKeys = []string{
+ "LISTENBRAINZ_USER", "LISTENBRAINZ_DISCOVERY",
+ "WEEKLY_EXPLORATION_SCHEDULE", "WEEKLY_EXPLORATION_FLAGS",
+ "WEEKLY_JAMS_SCHEDULE", "WEEKLY_JAMS_FLAGS",
+ "DAILY_JAMS_SCHEDULE", "DAILY_JAMS_FLAGS",
+ "EXPLO_SYSTEM", "SYSTEM_URL", "API_KEY", "LIBRARY_NAME",
+ "SYSTEM_USERNAME", "SYSTEM_PASSWORD", "PLAYLIST_DIR", "SLEEP", "PUBLIC_PLAYLIST",
+ "DOWNLOAD_DIR", "USE_SUBDIRECTORY",
+ "DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST",
+ "SLSKD_URL", "SLSKD_API_KEY",
+}
+
+// ConfigResponse is returned by GET /api/config.
+type ConfigResponse struct {
+ Values map[string]string `json:"values"`
+ Sources map[string]string `json:"sources"` // "env" | "file"
+}
+
+// configFields is the single source of truth for the settings this web UI
+// currently owns. VisibleWhen / RequiredWhen drive the settings UI; the wizard
+// uses bespoke HTML but references the same logical rules.
+var configFields = []FieldDef{
+ // ββ Discovery ββββββββββββββββββββββββββββββββββββββββββββββββββ
+ {
+ Key: "LISTENBRAINZ_USER", Label: "ListenBrainz Username",
+ Type: "text", Section: "discovery",
+ Placeholder: "e.g. musiclover42", Required: true,
+ },
+
+ // ββ Media System βββββββββββββββββββββββββββββββββββββββββββββββ
+ {
+ Key: "EXPLO_SYSTEM", Label: "Media System",
+ Type: "select", Section: "system", Required: true,
+ Options: []Option{
+ {Value: "jellyfin", Label: "Jellyfin"},
+ {Value: "emby", Label: "Emby"},
+ {Value: "plex", Label: "Plex"},
+ {Value: "subsonic", Label: "Subsonic"},
+ {Value: "mpd", Label: "MPD"},
+ },
+ },
+ {
+ Key: "SYSTEM_URL", Label: "Server URL",
+ Type: "url", Section: "system",
+ Placeholder: "e.g. http://192.168.1.100:8096",
+ VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: netSystems},
+ RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", In: netSystems},
+ },
+ {
+ Key: "API_KEY", Label: "API Key",
+ Type: "text", Section: "system",
+ VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems},
+ RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems},
+ },
+ {
+ Key: "LIBRARY_NAME", Label: "Library Name",
+ Type: "text", Section: "system",
+ Placeholder: "e.g. Music",
+ VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems},
+ },
+ {
+ Key: "SYSTEM_USERNAME", Label: "Username",
+ Type: "text", Section: "system",
+ VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"},
+ RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"},
+ },
+ {
+ Key: "SYSTEM_PASSWORD", Label: "Password",
+ Type: "password", Section: "system",
+ VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"},
+ RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"},
+ },
+ {
+ Key: "PLAYLIST_DIR", Label: "Playlist Directory",
+ Type: "text", Section: "system",
+ Hint: "Explo writes .m3u files here β MPD reads them as playlists.",
+ VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "mpd"},
+ RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "mpd"},
+ },
+ {
+ Key: "SLEEP", Label: "Library Scan Wait (minutes)",
+ Type: "text", Section: "system",
+ Placeholder: "2",
+ Hint: "How long to wait after triggering a library scan before creating playlists.",
+ VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: netSystems},
+ },
+ {
+ Key: "PUBLIC_PLAYLIST", Label: "Public Playlists",
+ Type: "text", Section: "system",
+ Hint: "Set to true to make playlists visible to all users (Subsonic).",
+ VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"},
+ },
+
+ // ββ Downloader βββββββββββββββββββββββββββββββββββββββββββββββββ
+ {
+ Key: "DOWNLOAD_DIR", Label: "Download directory",
+ Type: "text", Section: "downloader",
+ Placeholder: "e.g. /data/ or ./downloads/",
+ Required: true,
+ },
+ {
+ Key: "USE_SUBDIRECTORY", Label: "Use playlist subfolders",
+ Type: "text", Section: "downloader",
+ Hint: "When enabled, Explo creates a subfolder per playlist inside the download directory.",
+ },
+ {
+ Key: "YOUTUBE_API_KEY", Label: "YouTube API Key",
+ Type: "text", Section: "downloader",
+ Placeholder: "AIzaβ¦",
+ Hint: "Required when using YouTube. Enable the YouTube Data API v3.",
+ VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "youtube"},
+ RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "youtube"},
+ },
+ {
+ Key: "SLSKD_URL", Label: "Slskd URL",
+ Type: "url", Section: "downloader",
+ Placeholder: "e.g. http://192.168.1.100:5030",
+ VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"},
+ RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"},
+ },
+ {
+ Key: "SLSKD_API_KEY", Label: "Slskd API Key",
+ Type: "text", Section: "downloader",
+ VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"},
+ RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"},
+ },
+}
+
+// runEvent is an SSE event sent to connected browser clients.
+type runEvent struct {
+ typ string
+ data string
+}
+
+// RunStatus is returned by GET /api/run/status.
+type RunStatus struct {
+ Running bool `json:"running"`
+ ExitCode *int `json:"exit_code,omitempty"`
+}
+
+type manualRunState struct {
+ mu sync.Mutex
+ running bool
+ cancel context.CancelFunc
+ exitCode *int
+ logs []string
+ subscribers map[chan runEvent]struct{}
+}
+
+func newManualRunState() manualRunState {
+ return manualRunState{subscribers: make(map[chan runEvent]struct{})}
+}
+
+type Server struct {
+ configPath string
+ exploPath string
+ mux *http.ServeMux
+ manualRun manualRunState
+}
+
+func NewServer(configPath, exploPath string) *Server {
+ s := &Server{
+ configPath: configPath,
+ exploPath: exploPath,
+ mux: http.NewServeMux(),
+ manualRun: newManualRunState(),
+ }
+ s.registerRoutes()
+ return s
+}
+
+// spaFS returns the filesystem to serve the frontend from.
+// When WEB_DEV=true, serves directly from src/web/dist on disk so that
+// running "npm run build" reflects changes without recompiling the binary.
+func spaFS() (fs.FS, []byte) {
+ if os.Getenv("WEB_DEV") == "true" {
+ diskFS := os.DirFS("src/web/dist")
+ index, _ := fs.ReadFile(diskFS, "index.html")
+ return diskFS, index
+ }
+ embedded, _ := fs.Sub(distFiles, "dist")
+ index, _ := fs.ReadFile(embedded, "index.html")
+ return embedded, index
+}
+
+func (s *Server) registerRoutes() {
+ distFS, indexHTML := spaFS()
+ fileServer := http.FileServer(http.FS(distFS))
+
+ // SPA fallback: serve static assets when they exist, otherwise serve index.html.
+ s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/")
+ if path != "" {
+ if _, err := fs.Stat(distFS, path); err == nil {
+ fileServer.ServeHTTP(w, r)
+ return
+ }
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write(indexHTML)
+ })
+ s.mux.HandleFunc("GET /api/config", s.handleGetConfig)
+ s.mux.HandleFunc("GET /api/config/raw", s.handleGetConfigRaw)
+ s.mux.HandleFunc("POST /api/config", s.handleSaveConfig)
+ s.mux.HandleFunc("POST /api/config/reset", s.handleResetConfig)
+ s.mux.HandleFunc("POST /api/config/schedules", s.handleSaveSchedule)
+ s.mux.HandleFunc("POST /api/wizard/step1", s.handleWizardStep1)
+ s.mux.HandleFunc("POST /api/wizard/step2", s.handleWizardStep2)
+ s.mux.HandleFunc("POST /api/wizard/step3", s.handleWizardStep3)
+ s.mux.HandleFunc("GET /api/browse", s.handleBrowse)
+ s.mux.HandleFunc("POST /api/run", s.handleRun)
+ s.mux.HandleFunc("GET /api/run/events", s.handleRunEvents)
+ s.mux.HandleFunc("POST /api/run/stop", s.handleStopRun)
+ s.mux.HandleFunc("GET /api/run/status", s.handleRunStatus)
+ s.mux.HandleFunc("GET /api/logs", s.handleGetLog)
+}
+
+func (s *Server) Start(addr string) error {
+ s.initServerLog()
+ slog.Info("Explo web UI started", "addr", addr)
+ return http.ListenAndServe(addr, s.mux)
+}
+
+// ββ Logging ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+// logPath returns the path to the single rolling log file.
+func (s *Server) logPath() string {
+ return filepath.Join(filepath.Dir(s.configPath), "logs", "explo.log")
+}
+
+// initServerLog redirects the default slog handler so all server log output
+// goes to both stderr and the rolling log file.
+func (s *Server) initServerLog() {
+ lf, err := s.openRunLog()
+ if err != nil {
+ return
+ }
+ w := io.MultiWriter(os.Stderr, lf)
+ slog.SetDefault(slog.New(slog.NewTextHandler(w, nil)))
+}
+
+// openRunLog opens the single rolling log file in append mode.
+func (s *Server) openRunLog() (*os.File, error) {
+ p := s.logPath()
+ if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
+ return nil, err
+ }
+ return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
+}
+
+// handleGetLog returns the contents of the rolling log file.
+func (s *Server) handleGetLog(w http.ResponseWriter, r *http.Request) {
+ data, err := os.ReadFile(s.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")
+ w.Write(data)
+}
+
+// ββ Config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+// parseEnvText parses key=value lines, ignoring comments and blanks.
+func parseEnvText(text string) map[string]string {
+ out := map[string]string{}
+ for line := range strings.SplitSeq(text, "\n") {
+ t := strings.TrimSpace(line)
+ if t == "" || strings.HasPrefix(t, "#") {
+ continue
+ }
+ k, v, ok := strings.Cut(t, "=")
+ if !ok {
+ continue
+ }
+ if k = strings.TrimSpace(k); k != "" {
+ out[k] = strings.TrimSpace(v)
+ }
+ }
+ return out
+}
+
+// handleGetConfig returns resolved config as JSON: { values, sources }.
+// Sources are "env" when set via os.Environ (takes precedence), "file" otherwise.
+func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
+ data, err := os.ReadFile(s.configPath)
+ var fileValues map[string]string
+ if err == nil {
+ fileValues = parseEnvText(string(data))
+ } else {
+ fileValues = parseEnvText(string(sampleEnv))
+ }
+
+ values := make(map[string]string, len(allConfigKeys))
+ sources := make(map[string]string, len(allConfigKeys))
+ for _, key := range allConfigKeys {
+ if v, ok := os.LookupEnv(key); ok {
+ values[key] = v
+ sources[key] = "env"
+ } else if v := fileValues[key]; v != "" {
+ values[key] = v
+ sources[key] = "file"
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(ConfigResponse{Values: values, Sources: sources})
+}
+
+// handleGetConfigRaw returns the raw .env file contents as plain text.
+func (s *Server) handleGetConfigRaw(w http.ResponseWriter, r *http.Request) {
+ data, err := os.ReadFile(s.configPath)
+ if err != nil {
+ data = sampleEnv
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Write(data)
+}
+
+// handleSaveConfig writes the posted plain-text body directly to the .env file.
+func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) {
+ data, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if err := os.WriteFile(s.configPath, data, 0600); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleResetConfig resets all settings and restarts the container.
+func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) {
+ if err := os.WriteFile(s.configPath, sampleEnv, 0600); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ go func() {
+ time.Sleep(300 * time.Millisecond)
+ syscall.Kill(1, syscall.SIGTERM)
+ }()
+}
+
+// handleSaveSchedule updates a single playlist's schedule in the .env file.
+func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+ Day int `json:"day"` // 0=Sunβ¦6=Sat, -1=every day
+ Hour int `json:"hour"`
+ Minute int `json:"minute"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ envPrefixes := map[string]string{
+ "weekly-exploration": "WEEKLY_EXPLORATION",
+ "weekly-jams": "WEEKLY_JAMS",
+ "daily-jams": "DAILY_JAMS",
+ }
+ flagDefaults := map[string]string{
+ "weekly-exploration": "--playlist weekly-exploration",
+ "weekly-jams": "--playlist weekly-jams",
+ "daily-jams": "--playlist daily-jams",
+ }
+
+ prefix, ok := envPrefixes[body.Name]
+ if !ok {
+ http.Error(w, "unknown playlist name", http.StatusBadRequest)
+ return
+ }
+
+ updates := map[string]string{}
+ if body.Enabled {
+ dow := "*"
+ if body.Day >= 0 {
+ dow = fmt.Sprintf("%d", body.Day)
+ }
+ updates[prefix+"_SCHEDULE"] = fmt.Sprintf("%d %d * * %s", body.Minute, body.Hour, dow)
+ updates[prefix+"_FLAGS"] = flagDefaults[body.Name]
+ } else {
+ updates[prefix+"_SCHEDULE"] = ""
+ updates[prefix+"_FLAGS"] = ""
+ }
+
+ if err := updateEnvKeys(s.configPath, updates, sampleEnv); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// updateEnvKeys reads the env file (falling back to fallback if missing), updates the
+// given key=value pairs in-place preserving comments, and writes the result back.
+func updateEnvKeys(path string, updates map[string]string, fallback []byte) error {
+ data, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ data = fallback
+ } else if err != nil {
+ return err
+ }
+
+ lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
+ touched := make(map[string]bool)
+
+ for i, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+ key, _, ok := strings.Cut(trimmed, "=")
+ if !ok {
+ continue
+ }
+ key = strings.TrimSpace(key)
+ if val, ok := updates[key]; ok {
+ if val == "" {
+ lines[i] = "" // remove by blanking
+ } else {
+ lines[i] = key + "=" + val
+ }
+ touched[key] = true
+ }
+ }
+
+ // Append any keys that weren't already in the file
+ for k, v := range updates {
+ if !touched[k] && v != "" {
+ lines = append(lines, k+"="+v)
+ }
+ }
+
+ // Filter out consecutive blank lines left by removals
+ out := make([]string, 0, len(lines))
+ prevBlank := false
+ for _, l := range lines {
+ blank := strings.TrimSpace(l) == ""
+ if blank && prevBlank {
+ continue
+ }
+ out = append(out, l)
+ prevBlank = blank
+ }
+
+ return os.WriteFile(path, []byte(strings.Join(out, "\n")+"\n"), 0600)
+}
+
+// ββ Wizard βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+// handleWizardStep1 saves discovery settings (username + enabled playlists with default schedules).
+func (s *Server) handleWizardStep1(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ User string `json:"user"`
+ Playlists []string `json:"playlists"`
+ DiscoveryMode string `json:"discovery_mode"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ if body.User == "" {
+ http.Error(w, "user is required", http.StatusBadRequest)
+ return
+ }
+
+ type schedDef struct{ schedule, flags string }
+ defaults := map[string]schedDef{
+ "weekly-exploration": {"15 00 * * 2", "--playlist weekly-exploration"},
+ "weekly-jams": {"30 00 * * 1", "--playlist weekly-jams"},
+ "daily-jams": {"15 01 * * *", "--playlist daily-jams"},
+ }
+ envPrefixes := map[string]string{
+ "weekly-exploration": "WEEKLY_EXPLORATION",
+ "weekly-jams": "WEEKLY_JAMS",
+ "daily-jams": "DAILY_JAMS",
+ }
+
+ enabled := make(map[string]bool, len(body.Playlists))
+ for _, p := range body.Playlists {
+ enabled[p] = true
+ }
+
+ updates := map[string]string{
+ "LISTENBRAINZ_USER": body.User,
+ "LISTENBRAINZ_DISCOVERY": body.DiscoveryMode,
+ }
+ for playlist, prefix := range envPrefixes {
+ if enabled[playlist] {
+ d := defaults[playlist]
+ updates[prefix+"_SCHEDULE"] = d.schedule
+ updates[prefix+"_FLAGS"] = d.flags
+ } else {
+ updates[prefix+"_SCHEDULE"] = ""
+ updates[prefix+"_FLAGS"] = ""
+ }
+ }
+
+ if err := updateEnvKeys(s.configPath, updates, sampleEnv); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleWizardStep2 saves media system configuration.
+func (s *Server) handleWizardStep2(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ System string `json:"system"`
+ URL string `json:"url"`
+ APIKey string `json:"api_key"`
+ LibraryName string `json:"library_name"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ PlaylistDir string `json:"playlist_dir"`
+ Sleep string `json:"sleep"`
+ PublicPlaylist bool `json:"public_playlist"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ if body.System == "" {
+ http.Error(w, "system is required", http.StatusBadRequest)
+ return
+ }
+
+ publicPlaylist := ""
+ if body.PublicPlaylist {
+ publicPlaylist = "true"
+ }
+ updates := map[string]string{
+ "EXPLO_SYSTEM": body.System,
+ "SYSTEM_URL": body.URL,
+ "API_KEY": body.APIKey,
+ "LIBRARY_NAME": body.LibraryName,
+ "SYSTEM_USERNAME": body.Username,
+ "SYSTEM_PASSWORD": body.Password,
+ "PLAYLIST_DIR": body.PlaylistDir,
+ "SLEEP": body.Sleep,
+ "PUBLIC_PLAYLIST": publicPlaylist,
+ }
+
+ if err := updateEnvKeys(s.configPath, updates, sampleEnv); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleWizardStep3 saves downloader configuration.
+func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ DownloadDir string `json:"download_dir"`
+ UseSubdirectory bool `json:"use_subdirectory"`
+ MigrateDownloads bool `json:"migrate_downloads"`
+ DownloadServices []string `json:"download_services"`
+ YoutubeAPIKey string `json:"youtube_api_key"`
+ TrackExtension string `json:"track_extension"`
+ FilterList string `json:"filter_list"`
+ SlskdURL string `json:"slskd_url"`
+ SlskdAPIKey string `json:"slskd_api_key"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ if len(body.DownloadServices) == 0 {
+ http.Error(w, "at least one download service is required", http.StatusBadRequest)
+ return
+ }
+ joined := strings.Join(body.DownloadServices, ",")
+ hasYoutube := strings.Contains(joined, "youtube")
+ hasSlskd := strings.Contains(joined, "slskd")
+ if (hasYoutube || (hasSlskd && body.MigrateDownloads)) && body.DownloadDir == "" {
+ http.Error(w, "download_dir is required", http.StatusBadRequest)
+ return
+ }
+
+ useSubdir := "false"
+ if body.UseSubdirectory {
+ useSubdir = "true"
+ }
+ migrateDL := "false"
+ if body.MigrateDownloads {
+ migrateDL = "true"
+ }
+ updates := map[string]string{
+ "DOWNLOAD_DIR": body.DownloadDir,
+ "USE_SUBDIRECTORY": useSubdir,
+ "MIGRATE_DOWNLOADS": migrateDL,
+ "DOWNLOAD_SERVICES": joined,
+ "YOUTUBE_API_KEY": body.YoutubeAPIKey,
+ "TRACK_EXTENSION": body.TrackExtension,
+ "FILTER_LIST": body.FilterList,
+ "SLSKD_URL": body.SlskdURL,
+ "SLSKD_API_KEY": body.SlskdAPIKey,
+ }
+
+ if err := updateEnvKeys(s.configPath, updates, sampleEnv); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// 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")
+ json.NewEncoder(w).Encode([]string{})
+ 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")
+ json.NewEncoder(w).Encode(dirs)
+}
+
+// ββ Manual run βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+var errRunAlreadyStarted = errors.New("run already in progress")
+
+// handleRun starts an explo run in the background. Clients follow output via /api/run/events.
+func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
+ http.Error(w, "bad form data", http.StatusBadRequest)
+ return
+ }
+
+ args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"),
+ r.FormValue("persist") == "false", r.FormValue("exclude_local") == "true",
+ s.configPath)
+
+ if err := s.startRun(args); err != nil {
+ if errors.Is(err, errRunAlreadyStarted) {
+ http.Error(w, "a run is already in progress", http.StatusConflict)
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+ json.NewEncoder(w).Encode(s.currentRunStatus())
+}
+
+func (s *Server) startRun(args []string) error {
+ ctx, cancel := context.WithCancel(context.Background())
+ cmd := exec.CommandContext(ctx, s.exploPath, args...)
+ // Strip WEB_UI from env so the child process runs normally, not as web server.
+ 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
+
+ pr, pw, err := os.Pipe()
+ if err != nil {
+ cancel()
+ return fmt.Errorf("failed to create pipe: %w", err)
+ }
+ cmd.Stdout = pw
+ cmd.Stderr = pw
+
+ lf, err := s.openRunLog()
+ if err != nil {
+ slog.Warn("failed to open run log", "err", err)
+ }
+
+ s.manualRun.mu.Lock()
+ if s.manualRun.running {
+ s.manualRun.mu.Unlock()
+ cancel()
+ pr.Close()
+ pw.Close()
+ if lf != nil {
+ lf.Close()
+ }
+ return errRunAlreadyStarted
+ }
+ s.manualRun.running = true
+ s.manualRun.cancel = cancel
+ s.manualRun.exitCode = nil
+ s.manualRun.logs = nil
+ s.manualRun.mu.Unlock()
+
+ if err := cmd.Start(); err != nil {
+ s.finishRun(1)
+ cancel()
+ pr.Close()
+ pw.Close()
+ if lf != nil {
+ lf.Close()
+ }
+ return fmt.Errorf("failed to start explo: %w", err)
+ }
+
+ // Close write end in parent so reader gets EOF when child exits.
+ pw.Close()
+
+ go s.collectRunOutput(cmd, pr, lf)
+ return nil
+}
+
+func (s *Server) collectRunOutput(cmd *exec.Cmd, pr *os.File, lf *os.File) {
+ defer pr.Close()
+ if lf != nil {
+ defer lf.Close()
+ }
+
+ scanner := bufio.NewScanner(pr)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if lf != nil {
+ fmt.Fprintln(lf, line)
+ }
+ s.appendRunLog(line)
+ }
+ if err := scanner.Err(); err != nil {
+ s.appendRunLog("failed to read run output: " + err.Error())
+ }
+
+ code := 0
+ if err := cmd.Wait(); err != nil && cmd.ProcessState == nil {
+ code = 1
+ }
+ if cmd.ProcessState != nil {
+ code = cmd.ProcessState.ExitCode()
+ }
+ s.finishRun(code)
+}
+
+func (s *Server) handleStopRun(w http.ResponseWriter, r *http.Request) {
+ s.manualRun.mu.Lock()
+ cancel := s.manualRun.cancel
+ running := s.manualRun.running
+ s.manualRun.mu.Unlock()
+
+ if !running || cancel == nil {
+ http.Error(w, "no run is currently in progress", http.StatusConflict)
+ return
+ }
+
+ cancel()
+ w.WriteHeader(http.StatusAccepted)
+}
+
+func (s *Server) currentRunStatus() RunStatus {
+ s.manualRun.mu.Lock()
+ defer s.manualRun.mu.Unlock()
+
+ var exitCode *int
+ if s.manualRun.exitCode != nil {
+ code := *s.manualRun.exitCode
+ exitCode = &code
+ }
+ return RunStatus{Running: s.manualRun.running, ExitCode: exitCode}
+}
+
+func (s *Server) handleRunStatus(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(s.currentRunStatus())
+}
+
+// ββ SSE event stream βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+func (s *Server) appendRunLog(line string) {
+ event := runEvent{data: line}
+
+ s.manualRun.mu.Lock()
+ s.manualRun.logs = append(s.manualRun.logs, line)
+ subscribers := make([]chan runEvent, 0, len(s.manualRun.subscribers))
+ for ch := range s.manualRun.subscribers {
+ subscribers = append(subscribers, ch)
+ }
+ s.manualRun.mu.Unlock()
+
+ for _, ch := range subscribers {
+ select {
+ case ch <- event:
+ default:
+ }
+ }
+}
+
+func (s *Server) finishRun(code int) {
+ done := runEvent{typ: "done", data: fmt.Sprintf("%d", code)}
+
+ s.manualRun.mu.Lock()
+ s.manualRun.running = false
+ s.manualRun.cancel = nil
+ s.manualRun.exitCode = &code
+ subscribers := make([]chan runEvent, 0, len(s.manualRun.subscribers))
+ for ch := range s.manualRun.subscribers {
+ subscribers = append(subscribers, ch)
+ delete(s.manualRun.subscribers, ch)
+ }
+ s.manualRun.mu.Unlock()
+
+ for _, ch := range subscribers {
+ select {
+ case ch <- done:
+ default:
+ }
+ close(ch)
+ }
+}
+
+// handleRunEvents streams the current in-memory run log, then follows new lines
+// until the active run exits. Safe to reconnect after a browser refresh.
+func (s *Server) handleRunEvents(w http.ResponseWriter, r *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming not supported", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("X-Accel-Buffering", "no")
+
+ sendEvent := func(typ, data string) {
+ if typ != "" {
+ fmt.Fprintf(w, "event: %s\n", typ)
+ }
+ fmt.Fprintf(w, "data: %s\n\n", data)
+ flusher.Flush()
+ }
+
+ ch := make(chan runEvent, 256)
+ s.manualRun.mu.Lock()
+ lines := append([]string(nil), s.manualRun.logs...)
+ running := s.manualRun.running
+ var exitCode *int
+ if s.manualRun.exitCode != nil {
+ code := *s.manualRun.exitCode
+ exitCode = &code
+ }
+ if running {
+ s.manualRun.subscribers[ch] = struct{}{}
+ }
+ s.manualRun.mu.Unlock()
+
+ for _, line := range lines {
+ sendEvent("", line)
+ }
+ if !running {
+ if exitCode != nil {
+ sendEvent("done", fmt.Sprintf("%d", *exitCode))
+ }
+ return
+ }
+
+ defer s.unsubscribeRun(ch)
+ for {
+ select {
+ case <-r.Context().Done():
+ return
+ case ev, ok := <-ch:
+ if !ok {
+ return
+ }
+ sendEvent(ev.typ, ev.data)
+ if ev.typ == "done" {
+ return
+ }
+ }
+ }
+}
+
+func (s *Server) unsubscribeRun(ch chan runEvent) {
+ s.manualRun.mu.Lock()
+ delete(s.manualRun.subscribers, ch)
+ s.manualRun.mu.Unlock()
+}
+
+// ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, cfgPath string) []string {
+ args := []string{"--config", cfgPath}
+ if playlist != "" {
+ args = append(args, "--playlist", playlist)
+ }
+ if downloadMode != "" {
+ args = append(args, "--download-mode", downloadMode)
+ }
+ if noPersist {
+ args = append(args, "--persist=false")
+ }
+ if excludeLocal {
+ args = append(args, "--exclude-local")
+ }
+ return args
+}