diff --git a/src/client/client.go b/src/client/client.go index 2deae0b8..a38c57e4 100644 --- a/src/client/client.go +++ b/src/client/client.go @@ -162,7 +162,6 @@ func (c *Client) systemSetup() error { if err := c.API.GetAuth(); err != nil { return err } - } if err := c.API.AddHeader(); err != nil { diff --git a/src/client/plex.go b/src/client/plex.go index 6f0880c8..39a4ce69 100644 --- a/src/client/plex.go +++ b/src/client/plex.go @@ -283,7 +283,7 @@ func (c *Plex) GetAuth() error { // Get user token from plex return nil } func (c *Plex) GetLibrary() error { - if c.Cfg.AdminCreds.User != "" && c.Cfg.AdminCreds.Password != "" { + if (c.Cfg.AdminCreds.User != "" && c.Cfg.AdminCreds.Password != "") { adminCfg := c.Cfg adminCfg.Creds = config.Credentials{ User: c.Cfg.AdminCreds.User, @@ -304,6 +304,23 @@ func (c *Plex) GetLibrary() error { } c.LibraryID = c.AdminClient.LibraryID + return err + } else if (c.Cfg.AdminCreds.APIKey != "") { + adminCfg := c.Cfg + adminCfg.Creds = config.Credentials{ + APIKey: c.Cfg.AdminCreds.APIKey, + } + + c.AdminClient = NewPlex(adminCfg, c.HttpClient) + if err := c.AdminClient.AddHeader(); err != nil { + return err + } + err := c.AdminClient.getLibraryRequest() + if err != nil { + return err + } + c.LibraryID = c.AdminClient.LibraryID + return err } return c.getLibraryRequest() diff --git a/src/config/config.go b/src/config/config.go index f70a7ce2..7ecc1aca 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -81,6 +81,7 @@ type Credentials struct { } type AdminCredentials struct { + APIKey string `env:"ADMIN_API_KEY"` User string `env:"ADMIN_SYSTEM_USERNAME"` Password string `env:"ADMIN_SYSTEM_PASSWORD"` } diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 5b808e8c..27ef6db0 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -692,12 +692,18 @@ func (s *Server) handleWizardStep2(w http.ResponseWriter, r *http.Request) { Password string `json:"password"` PlaylistDir string `json:"playlist_dir"` Sleep string `json:"sleep"` + AdminAPIKey string `json:"admin_api_key"` + AdminSystemUsername string `json:"admin_system_username"` + AdminSystemPassword string `json:"admin_system_password"` + 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 @@ -717,6 +723,9 @@ func (s *Server) handleWizardStep2(w http.ResponseWriter, r *http.Request) { "PLAYLIST_DIR": body.PlaylistDir, "SLEEP": body.Sleep, "PUBLIC_PLAYLIST": publicPlaylist, + "ADMIN_SYSTEM_USERNAME": body.AdminSystemUsername, + "ADMIN_SYSTEM_PASSWORD": body.AdminSystemPassword, + "ADMIN_SYSTEM_APIKEY": body.AdminAPIKey, } if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil { diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index c5b4a7d8..eb9a7c31 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -22,9 +22,9 @@ const NextBtn = ({ onClick, disabled, saving, label = 'Next →' }) => ( disabled={disabled || saving} className="bg-accent text-white rounded-[6px] px-6 py-2.5 text-[14px] border-none cursor-pointer hover:opacity-85 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity" > - {saving ? 'Saving…' : label} + {saving ? "Saving…" : label} -) +); const BackBtn = ({ onClick }) => ( -) +); // ── Step 1: Discovery ───────────────────────────────────────────────────────── // Collects the ListenBrainz username, discovery mode (playlist vs API), and @@ -43,7 +43,7 @@ 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 @@ -54,7 +54,8 @@ function Step1({ fields, setField, envSources, onNext, saving }) {
Step 1 of 3 — Discovery

- Explo uses your ListenBrainz listening history to find music recommendations. + Explo uses your ListenBrainz listening history to find music + recommendations.

@@ -63,6 +64,33 @@ function Step1({ fields, setField, envSources, onNext, saving }) { setField('user', e.target.value)} disabled={isLocked('LISTENBRAINZ_USER')} /> + label="ListenBrainz username" + labelFor="lb-user" + hint={ + <> + Don't have an account?{" "} + + Sign up free. + + + } + > + setField("user", e.target.value)} + disabled={isLocked("LISTENBRAINZ_USER")} + />
@@ -74,9 +102,9 @@ function Step1({ fields, setField, envSources, onNext, saving }) { ].map(m => (
- {discoveryMode === 'playlist' && ( + {discoveryMode === "playlist" && (
- {PLAYLISTS.map(p => ( + {PLAYLISTS.map((p) => (
- ) + ); } // ── Step 2: Media System ────────────────────────────────────────────────────── // Collects the media server type and its credentials. Fields shown/hidden // conditionally based on which system is selected. +function SegmentedControl({ value, onChange, options }) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} const SYSTEMS = [ - { value: 'jellyfin', name: 'Jellyfin' }, - { value: 'emby', name: 'Emby' }, - { value: 'plex', name: 'Plex' }, - { value: 'subsonic', name: 'Subsonic' }, - { value: 'mpd', name: 'MPD' }, -] + { 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'] +const API_KEY_SYSTEMS = ["emby", "plex"]; +const ADMIN_SYSTEMS = ["plex", "subsonic"]; 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 { + system, + systemUrl, + authMethod, + apiKey, + libraryName, + systemUsername, + systemPassword, + adminAuthMethod, + adminApiKey, + adminCredentials, + adminSystemUsername, + adminSystemPassword, + playlistDir, + sleepMinutes, + publicPlaylist, + } = fields; + const isLocked = (key) => envSources[key] === ".env"; const urlPlaceholder = () => { const ports = { jellyfin: '8096', emby: '8096', plex: '32400', subsonic: '4533' } @@ -135,31 +196,42 @@ function Step2({ fields, setField, envSources, onBack, onNext, saving }) { } 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 - } + if (!system) return false; + if (system === "mpd") return playlistDir.trim() !== ""; + if (!systemUrl.trim()) return false; + if (API_KEY_SYSTEMS.includes(system)) { + if (authMethod === "apikey" && !apiKey.trim()) return false; + if (authMethod === "password" && (!systemUsername.trim() || !systemPassword.trim())) return false; + } + if (system === "jellyfin" && ( !systemUsername.trim() || !apiKey.trim() ) ) return false; + if (system === "subsonic" && (!systemUsername.trim() || !systemPassword.trim())) return false; + if (adminCredentials) { + if (adminAuthMethod == "apikey" && !adminApiKey?.trim()) return false; + if (adminAuthMethod == "password" && (!adminSystemUsername?.trim() || !adminSystemPassword?.trim())) 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. + Explo will add discovered tracks to your library and create playlists + automatically. It needs access to your media server to do this.

- {SYSTEMS.map(s => ( + {SYSTEMS.map((s) => ( @@ -167,31 +239,194 @@ function Step2({ fields, setField, envSources, onBack, onNext, saving }) {
- {system && system !== 'mpd' && ( + {system && system !== "mpd" && ( setField('systemUrl', e.target.value)} placeholder={urlPlaceholder()} disabled={isLocked('SYSTEM_URL')} /> )} - - {API_KEY_SYSTEMS.includes(system) && ( - - setField('apiKey', e.target.value)} - autoComplete="off" spellCheck={false} disabled={isLocked('API_KEY')} /> - + {system && system == "jellyfin" && ( + <> + + setField("apiKey", e.target.value)} + autoComplete="off" + spellCheck={false} + disabled={isLocked("API_KEY")} + /> + + + setField("systemUsername", e.target.value)} + autoComplete="off" + spellCheck={false} + disabled={isLocked("SYSTEM_USERNAME")} + /> + + + setField("libraryName", e.target.value)} + placeholder="e.g. Music" + disabled={isLocked("LIBRARY_NAME")} + /> + + )} - {API_KEY_SYSTEMS.includes(system) && ( - - setField('libraryName', e.target.value)} - placeholder="e.g. Music" disabled={isLocked('LIBRARY_NAME')} /> - + <> + + setField("authMethod", v)} + options={[ + { + value: "password", + label: "Username & Password", + }, + { + value: "apikey", + label: "API Key", + }, + ]} + /> + + + {authMethod === "apikey" ? ( + + setField("apiKey", e.target.value)} + autoComplete="off" + spellCheck={false} + disabled={isLocked("API_KEY")} + /> + + ) : ( + <> + + setField("systemUsername", e.target.value)} + disabled={isLocked("SYSTEM_USERNAME")} + /> + + + + setField("systemPassword", e.target.value)} + disabled={isLocked("SYSTEM_PASSWORD")} + /> + + + )} + + setField("libraryName", e.target.value)} + placeholder="e.g. Music" + disabled={isLocked("LIBRARY_NAME")} + /> + +
+ + + + setField("adminAuthMethod", v)} + options={[ + { + value: "password", + label: "Username & Password", + }, + { + value: "apikey", + label: "API Key", + }, + ]} + /> + {adminAuthMethod === "apikey" ? ( + + setField("adminApiKey", e.target.value)} + /> + + ) : ( + <> + + + setField("adminSystemUsername", e.target.value) + } + /> + + + + + setField("adminSystemPassword", e.target.value) + } + /> + + + )} + +
+ )} - {system === 'subsonic' && ( + {system === "subsonic" && ( <> - setField('systemUsername', e.target.value)} + setField('systemUsername', e.target.value)} autoComplete="off" disabled={isLocked('SYSTEM_USERNAME')} /> @@ -217,11 +452,11 @@ function Step2({ fields, setField, envSources, onBack, onNext, saving }) { )} - {system === 'subsonic' && ( + {system === "subsonic" && ( setField('publicPlaylist', v)} - disabled={isLocked('PUBLIC_PLAYLIST')} + onChange={(v) => setField("publicPlaylist", v)} + disabled={isLocked("PUBLIC_PLAYLIST")} name="Public playlists" desc="Make playlists visible to all users on the server" /> @@ -233,7 +468,7 @@ function Step2({ fields, setField, envSources, onBack, onNext, saving }) {
- ) + ); } function Collapse({ open, children }) { @@ -247,7 +482,7 @@ function Collapse({ open, children }) { {children}
- ) + ); } // ── Step 3: Downloader ──────────────────────────────────────────────────────── @@ -255,25 +490,36 @@ function Collapse({ open, children }) { // 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, extensions } = fields - const isLocked = key => envSources[key] === 'env' + const { + downloadDir, + useSubdirectory, + migrateDownloads, + dlServices, + youtubeApiKey, + trackExtension, + filterList, + slskdUrl, + slskdApiKey, + extensions, + } = fields; + const isLocked = (key) => envSources[key] === "env"; const valid = () => { - if (!Object.values(dlServices).some(Boolean)) return false - if (dlServices.slskd && (!slskdUrl.trim() || !slskdApiKey.trim())) return false - return true - } + if (!Object.values(dlServices).some(Boolean)) 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. + Explo downloads tracks using one or both services. Enable what you have + access to — if both are enabled, YouTube is tried first.

- {/* YouTube section */}
setField('useSubdirectory', v)} - disabled={isLocked('USE_SUBDIRECTORY')} + onChange={(v) => setField("useSubdirectory", v)} + disabled={isLocked("USE_SUBDIRECTORY")} name="Use playlist subfolders" desc="Create a subfolder per playlist inside the download directory" /> @@ -349,12 +595,13 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) {

- By default, slskd saves tracks to whichever download path is configured in your slskd instance. + By default, slskd saves tracks to whichever download path is + configured in your slskd instance.

setField('migrateDownloads', v)} - disabled={isLocked('MIGRATE_DOWNLOADS')} + onChange={(v) => setField("migrateDownloads", v)} + disabled={isLocked("MIGRATE_DOWNLOADS")} desc="Move completed downloads to a separate directory after transfer" />
@@ -368,8 +615,8 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { setField('useSubdirectory', v)} - disabled={isLocked('USE_SUBDIRECTORY')} + onChange={(v) => setField("useSubdirectory", v)} + disabled={isLocked("USE_SUBDIRECTORY")} name="Use playlist subfolders" desc="Create a subfolder per playlist inside the download directory" /> @@ -385,127 +632,181 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) {
- ) + ); } // ── 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, bgUrl, bgLoaded, onBgLoad, onComplete }) { - const [step, setStep] = useState(1) - const [saving, setSaving] = useState(false) +export default function Wizard({ + config, + envSources, + bgUrl, + bgLoaded, + onBgLoad, + onComplete, +}) { + const [step, setStep] = useState(1); + const [saving, setSaving] = useState(false); const [fields, setFields] = useState(() => { - const s = (config.DOWNLOAD_SERVICES || '').split(',') + const s = (config.DOWNLOAD_SERVICES || "").split(","); return { // Step 1 - user: config.LISTENBRAINZ_USER || '', - discoveryMode: config.LISTENBRAINZ_DISCOVERY || 'playlist', + 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, + "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', + 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 || '', - extensions: config.EXTENSIONS || '', - } - }) - - const setField = (key, val) => setFields(prev => ({ ...prev, [key]: val })) + 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 || "", + extensions: config.EXTENSIONS || "", + adminAuthMethod: config.ADMIN_AUTH_METHOD || "password", + adminApiKey: config.ADMIN_API_KEY || "", + adminSystemUsername: config.ADMIN_SYSTEM_USERNAME || "", + adminSystemPassword: config.ADMIN_SYSTEM_PASSWORD || "", + }; + }); + + 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) + .filter( + ([k, s]) => + s === "env" && !k.endsWith("_SCHEDULE") && !k.endsWith("_FLAGS"), + ) + .map(([k]) => k); async function handleStep1() { - setSaving(true) + setSaving(true); try { - const playlists = Object.entries(fields.checked).filter(([, v]) => v).map(([k]) => k) - await wizardStep1(fields.user.trim(), playlists, fields.discoveryMode) + const playlists = Object.entries(fields.checked) + .filter(([, v]) => v) + .map(([k]) => k); + await wizardStep1(fields.user.trim(), playlists, fields.discoveryMode); if (playlists.length > 0) { - prefetchPlaylists(fields.user.trim(), playlists, { source: 'wizard' }).catch(() => {}) + prefetchPlaylists(fields.user.trim(), playlists, { + source: "wizard", + }).catch(() => {}); } - setStep(2) + setStep(2); } catch (e) { - alert('Error saving: ' + e.message) + alert("Error saving: " + e.message); } finally { - setSaving(false) + setSaving(false); } } async function handleStep2() { - setSaving(true) + 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) + system: fields.system, + url: fields.systemUrl, + + auth_method: fields.authMethod, + + api_key: fields.apiKey, + username: fields.systemUsername, + password: fields.systemPassword, + + admin_credentials: fields.adminCredentials, + admin_auth_method: fields.adminAuthMethod, + admin_api_key: fields.adminApiKey, + admin_system_username: fields.adminSystemUsername, + admin_system_password: fields.adminSystemPassword, + + library_name: fields.libraryName, + playlist_dir: fields.playlistDir, + sleep: fields.sleepMinutes, + public_playlist: fields.publicPlaylist, + }); + setStep(3); } catch (e) { - alert('Error saving: ' + e.message) + alert("Error saving: " + e.message); } finally { - setSaving(false) + setSaving(false); } } async function handleStep3() { - setSaving(true) + setSaving(true); try { - const services = Object.entries(fields.dlServices).filter(([, v]) => v).map(([k]) => k) + 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, + 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, extensions: fields.extensions, - }) - onComplete() + }); + onComplete(); } catch (e) { - alert('Error saving: ' + e.message) + alert("Error saving: " + e.message); } finally { - setSaving(false) + setSaving(false); } } return ( -
- +
{/* Artwork backdrop — blurred ambient glow, matches the Settings page treatment */} -
+
{bgUrl && ( @@ -513,39 +814,49 @@ export default function Wizard({ config, envSources, bgUrl, bgLoaded, onBgLoad,
-
Explo
+
+ Explo +
{lockedKeys.length > 0 && (
- You've set the following in your Docker environment, so they can't be changed here:{' '} - {lockedKeys.join(', ')} + 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} + onBack={() => setStep(1)} + onNext={handleStep2} + saving={saving} /> )} {step === 3 && ( setStep(2)} onFinish={handleStep3} saving={saving} + onBack={() => setStep(2)} + onFinish={handleStep3} + saving={saving} /> )}
- ) + ); }