Enable TPDB UI, add settings page for API keys, and wire AE imports

This commit is contained in:
Stu Leak 2025-12-04 12:14:21 -05:00
parent e3d253ba92
commit fa4423acfe
16 changed files with 847 additions and 57 deletions

View File

@ -3,3 +3,8 @@ TPDB_API_KEY=your-api-key-here
# Adult Empire API Key (if enabled)
AE_API_KEY=your-api-key-here
# StashDB / stash-box
STASHDB_API_KEY=your-api-key-here
# Optional custom endpoint (default stashdb.org GraphQL URL)
STASHDB_ENDPOINT=https://stashdb.org/graphql

1
.gitignore vendored
View File

@ -20,6 +20,7 @@
# Cache directories
/cache/
/tmp/
/config/api_keys.json
# Node modules (Bootstrap)
node_modules/

View File

@ -165,6 +165,7 @@ go run ./cmd/goondex performer-search "test"
### Scripts
- `source scripts/env.sh` - Pin Go caches inside the repo (recommended before building)
- `source scripts/load-env.sh` - Load API keys from `.env.local` (or `.env`) without hardcoding them
- `scripts/build.sh` - Build the CLI (`bin/goondex`)
- `ADDR=localhost:8788 scripts/run.sh` - Build (if needed) and start the web UI
- `scripts/test.sh` - Run `go test ./cmd/... ./internal/...`

View File

@ -9,6 +9,7 @@ import (
"time"
"git.leaktechnologies.dev/stu/Goondex/internal/db"
"git.leaktechnologies.dev/stu/Goondex/internal/config"
"git.leaktechnologies.dev/stu/Goondex/internal/model"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper/adultemp"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper/merger"
@ -19,7 +20,7 @@ import (
)
const tpdbAPIKeyEnvVar = "TPDB_API_KEY"
const tpdbEnabled = false
const tpdbEnabled = true
var (
dbPath string
@ -440,7 +441,7 @@ func getTPDBAPIKey() (string, error) {
return "", fmt.Errorf("TPDB integration is disabled. Use Adult Empire commands (e.g., 'goondex adultemp search-performer' and 'goondex adultemp scrape-performer <url>') to import data instead.")
}
apiKey := os.Getenv(tpdbAPIKeyEnvVar)
apiKey := config.GetAPIKeys().TPDBAPIKey
if apiKey == "" {
return "", fmt.Errorf("%s environment variable is not set.\n%s", tpdbAPIKeyEnvVar, apiKeySetupInstructions())
}

96
internal/config/keys.go Normal file
View File

@ -0,0 +1,96 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// APIKeys holds external service credentials.
type APIKeys struct {
TPDBAPIKey string `json:"tpdb_api_key"`
AEAPIKey string `json:"ae_api_key"`
StashDBAPIKey string `json:"stashdb_api_key"`
StashDBEndpoint string `json:"stashdb_endpoint"`
}
const apiKeysFile = "config/api_keys.json"
var (
keysMu sync.RWMutex
cached *APIKeys
)
// GetAPIKeys returns the configured API keys, preferring the persisted file, falling back to environment variables.
func GetAPIKeys() APIKeys {
keysMu.RLock()
if cached != nil {
defer keysMu.RUnlock()
return *cached
}
keysMu.RUnlock()
keys := APIKeys{}
// Try file first.
if b, err := os.ReadFile(apiKeysFile); err == nil {
_ = json.Unmarshal(b, &keys)
}
// Fallback to env if fields are empty.
if keys.TPDBAPIKey == "" {
keys.TPDBAPIKey = os.Getenv("TPDB_API_KEY")
}
if keys.AEAPIKey == "" {
keys.AEAPIKey = os.Getenv("AE_API_KEY")
}
if keys.StashDBAPIKey == "" {
keys.StashDBAPIKey = os.Getenv("STASHDB_API_KEY")
}
if keys.StashDBEndpoint == "" {
keys.StashDBEndpoint = os.Getenv("STASHDB_ENDPOINT")
}
if keys.StashDBEndpoint == "" {
keys.StashDBEndpoint = "https://stashdb.org/graphql"
}
keysMu.Lock()
cached = &keys
keysMu.Unlock()
return keys
}
// SaveAPIKeys persists API keys to disk (config/api_keys.json) and updates cache.
func SaveAPIKeys(keys APIKeys) error {
keysMu.Lock()
defer keysMu.Unlock()
// Normalize whitespace.
keys.TPDBAPIKey = strings.TrimSpace(keys.TPDBAPIKey)
keys.AEAPIKey = strings.TrimSpace(keys.AEAPIKey)
keys.StashDBAPIKey = strings.TrimSpace(keys.StashDBAPIKey)
keys.StashDBEndpoint = strings.TrimSpace(keys.StashDBEndpoint)
if keys.StashDBEndpoint == "" {
keys.StashDBEndpoint = "https://stashdb.org/graphql"
}
if err := os.MkdirAll(filepath.Dir(apiKeysFile), 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := json.MarshalIndent(keys, "", " ")
if err != nil {
return fmt.Errorf("marshal keys: %w", err)
}
if err := os.WriteFile(apiKeysFile, data, 0o600); err != nil {
return fmt.Errorf("write keys file: %w", err)
}
cached = &keys
return nil
}

View File

@ -7,14 +7,17 @@ import (
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"git.leaktechnologies.dev/stu/Goondex/internal/db"
import_service "git.leaktechnologies.dev/stu/Goondex/internal/import"
"git.leaktechnologies.dev/stu/Goondex/internal/model"
"git.leaktechnologies.dev/stu/Goondex/internal/config"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper/adultemp"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper/tpdb"
"git.leaktechnologies.dev/stu/Goondex/internal/sync"
)
@ -78,6 +81,16 @@ func (s *Server) Start() error {
mux.HandleFunc("/scenes/", s.handleSceneDetail)
mux.HandleFunc("/movies", s.handleMovieList)
mux.HandleFunc("/movies/", s.handleMovieDetail)
mux.HandleFunc("/settings", s.handleSettingsPage)
// Adult Empire endpoints
mux.HandleFunc("/api/ae/import/performer", s.handleAEImportPerformer)
mux.HandleFunc("/api/ae/import/performer-by-url", s.handleAEImportPerformerByURL)
mux.HandleFunc("/api/ae/import/scene", s.handleAEImportScene)
mux.HandleFunc("/api/ae/import/scene-by-url", s.handleAEImportSceneByURL)
// Settings endpoints
mux.HandleFunc("/api/settings/api-keys", s.handleAPISettingsKeys)
// API
mux.HandleFunc("/api/import/performer", s.handleAPIImportPerformer)
@ -106,6 +119,13 @@ func (s *Server) Start() error {
return http.ListenAndServe(s.addr, mux)
}
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
log.Printf("template render error (%s): %v", name, err)
http.Error(w, fmt.Sprintf("template render error: %v", err), http.StatusInternalServerError)
}
}
// ============================================================================
// PAGE HANDLERS
// ============================================================================
@ -135,7 +155,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
"MovieCount": len(movies),
}
s.templates.ExecuteTemplate(w, "dashboard.html", data)
s.render(w, "dashboard.html", data)
}
func (s *Server) handlePerformerList(w http.ResponseWriter, r *http.Request) {
@ -565,7 +585,7 @@ type APIResponse struct {
Data interface{} `json:"data,omitempty"`
}
const tpdbEnabled = false
const tpdbEnabled = true
const tpdbDisabledMessage = "TPDB integration is disabled. Use the Adult Empire CLI commands to import and enrich data for now."
func tpdbAPIKey() (string, error) {
@ -573,7 +593,7 @@ func tpdbAPIKey() (string, error) {
return "", fmt.Errorf(tpdbDisabledMessage)
}
apiKey := os.Getenv("TPDB_API_KEY")
apiKey := config.GetAPIKeys().TPDBAPIKey
if apiKey == "" {
return "", fmt.Errorf("TPDB_API_KEY not configured")
}
@ -841,6 +861,213 @@ func (s *Server) handleAPISyncStatus(w http.ResponseWriter, r *http.Request) {
})
}
// ============================================================================
// Adult Empire API (search + scrape)
// ============================================================================
func (s *Server) handleAEImportPerformer(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Query string `json:"query"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Query) == "" {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: "query is required"})
return
}
scraper, err := adultemp.NewScraper()
if err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("scraper init failed: %v", err)})
return
}
results, err := scraper.SearchPerformersByName(r.Context(), req.Query)
if err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("search failed: %v", err)})
return
}
if len(results) == 0 {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: "No performers found on Adult Empire"})
return
}
top := results[0]
data, err := scraper.ScrapePerformerByURL(r.Context(), top.URL)
if err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("scrape failed: %v", err)})
return
}
performer := scraper.ConvertPerformerToModel(data)
store := db.NewPerformerStore(s.db)
if err := store.Create(performer); err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("save failed: %v", err)})
return
}
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Imported %s from Adult Empire", performer.Name),
Data: map[string]interface{}{
"name": performer.Name,
"url": top.URL,
},
})
}
func (s *Server) handleAEImportPerformerByURL(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.URL) == "" {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: "url is required"})
return
}
scraper, err := adultemp.NewScraper()
if err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("scraper init failed: %v", err)})
return
}
data, err := scraper.ScrapePerformerByURL(r.Context(), req.URL)
if err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("scrape failed: %v", err)})
return
}
performer := scraper.ConvertPerformerToModel(data)
store := db.NewPerformerStore(s.db)
if err := store.Create(performer); err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("save failed: %v", err)})
return
}
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Imported %s from Adult Empire", performer.Name),
Data: map[string]interface{}{
"name": performer.Name,
"url": req.URL,
},
})
}
func (s *Server) handleAEImportScene(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Query string `json:"query"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Query) == "" {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: "query is required"})
return
}
scraper, err := adultemp.NewScraper()
if err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("scraper init failed: %v", err)})
return
}
results, err := scraper.SearchScenesByName(r.Context(), req.Query)
if err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("search failed: %v", err)})
return
}
if len(results) == 0 {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: "No scenes found on Adult Empire"})
return
}
top := results[0]
data, err := scraper.ScrapeSceneByURL(r.Context(), top.URL)
if err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("scrape failed: %v", err)})
return
}
scene := scraper.ConvertSceneToModel(data)
sceneStore := db.NewSceneStore(s.db)
if err := sceneStore.Create(scene); err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("save failed: %v", err)})
return
}
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Imported scene: %s", scene.Title),
Data: map[string]interface{}{
"title": scene.Title,
"url": top.URL,
},
})
}
func (s *Server) handleAEImportSceneByURL(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.URL) == "" {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: "url is required"})
return
}
scraper, err := adultemp.NewScraper()
if err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("scraper init failed: %v", err)})
return
}
data, err := scraper.ScrapeSceneByURL(r.Context(), req.URL)
if err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("scrape failed: %v", err)})
return
}
scene := scraper.ConvertSceneToModel(data)
sceneStore := db.NewSceneStore(s.db)
if err := sceneStore.Create(scene); err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("save failed: %v", err)})
return
}
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Imported scene: %s", scene.Title),
Data: map[string]interface{}{
"title": scene.Title,
"url": req.URL,
},
})
}
// ============================================================================
// BULK IMPORT ENDPOINTS + SSE PROGRESS
// ============================================================================
@ -1128,3 +1355,75 @@ func (s *Server) handleAPIGlobalSearch(w http.ResponseWriter, r *http.Request) {
Data: results,
})
}
// ============================================================================
// SETTINGS
// ============================================================================
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/settings" {
http.NotFound(w, r)
return
}
data := map[string]interface{}{
"PageTitle": "Settings",
"ActivePage": "settings",
}
s.render(w, "settings.html", data)
}
type apiKeysPayload struct {
TPDBAPIKey string `json:"tpdb_api_key"`
AEAPIKey string `json:"ae_api_key"`
StashDBAPIKey string `json:"stashdb_api_key"`
StashDBEndpoint string `json:"stashdb_endpoint"`
}
func (s *Server) handleAPISettingsKeys(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
keys := config.GetAPIKeys()
resp := map[string]interface{}{
"tpdbConfigured": keys.TPDBAPIKey != "",
"aeConfigured": keys.AEAPIKey != "",
"stashdbConfigured": keys.StashDBAPIKey != "",
"stashdbEndpoint": keys.StashDBEndpoint,
"tpdb_api_key": keys.TPDBAPIKey, // local-only UI; if you prefer, mask these
"ae_api_key": keys.AEAPIKey,
"stashdb_api_key": keys.StashDBAPIKey,
"stashdb_endpoint": keys.StashDBEndpoint, // duplicate for UI convenience
}
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: "OK",
Data: resp,
})
case http.MethodPost:
var payload apiKeysPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: "Invalid JSON"})
return
}
keys := config.APIKeys{
TPDBAPIKey: payload.TPDBAPIKey,
AEAPIKey: payload.AEAPIKey,
StashDBAPIKey: payload.StashDBAPIKey,
StashDBEndpoint: payload.StashDBEndpoint,
}
if err := config.SaveAPIKeys(keys); err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("Failed to save keys: %v", err)})
return
}
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: "API keys saved",
})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}

View File

@ -524,6 +524,22 @@ main.container {
color: var(--color-text-secondary);
}
.status-banner {
display: none;
padding: 0.75rem 1rem;
margin-top: 1rem;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-bg-elevated);
color: var(--color-text-primary);
font-size: 0.95rem;
}
.status-banner.error {
border-color: #ff8a8a;
color: #ff8a8a;
}
/* Detail views */
.breadcrumb {
margin-bottom: 1.5rem;

View File

@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="180"
height="110"
viewBox="0 0 180 110"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="GOONDEX_Titty.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
showgrid="true"
inkscape:zoom="2.8507295"
inkscape:cx="236.07992"
inkscape:cy="43.497638"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="5"
enabled="true"
visible="true" />
<inkscape:page
x="0"
y="0"
width="180"
height="110"
id="page1"
margin="0"
bleed="0" />
<inkscape:page
x="190"
y="0"
width="180"
height="110"
id="page2"
margin="0"
bleed="0" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
id="path13-4"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#ff5fa2;fill-opacity:1;stroke-width:9.80478"
d="M 54.6505,5 C 26.0598,5 5,25.826486 5,54.299551 c 0,28.473073 21.1791,49.297109 49.7699,49.297109 14.0881,0 26.3458,-5.056266 35.1585,-13.592646 8.8417,8.53638 21.1312,13.592646 35.2194,13.592646 28.5909,0 49.6506,-20.824036 49.6506,-49.297109 C 174.7984,25.826486 153.6218,5 125.0311,5 110.9429,5 98.6826,10.056275 89.8699,18.592651 81.0283,10.056273 68.7387,5 54.6505,5 Z m 0,11.294718 c 11.3038,0 21.0508,4.346057 27.7603,11.69192 3.2744,3.584891 5.8258,7.884457 7.4811,12.742197 1.6471,-4.864244 4.1887,-9.169044 7.4542,-12.756817 6.6784,-7.337146 16.3885,-11.6773 27.685,-11.6773 21.5312,0 37.415,15.767597 37.415,38.004833 0,22.119593 -15.8844,38.002393 -37.2982,38.002393 -11.2943,0 -21.0343,-4.37015 -27.7432,-11.71873 -3.2869,-3.60007 -5.8476,-7.915358 -7.5056,-12.781188 -1.6544,4.859897 -4.2044,9.171568 -7.4786,12.769018 -6.6954,7.35651 -16.4112,11.7309 -27.6506,11.7309 -21.5314,0 -37.4152,-15.88281 -37.4152,-38.002393 0,-22.237236 15.7644,-38.004833 37.2958,-38.004833 z m -0.9383,51.341619 c -2.6247,1.49e-4 -4.7519,2.129552 -4.7493,4.754267 1e-4,2.62286 2.1265,4.74679 4.7493,4.74695 2.623,-1.6e-4 4.7491,-2.12409 4.7495,-4.74695 0,-2.624715 -2.1248,-4.754118 -4.7495,-4.754267 z m 70.4489,0 c -2.6247,1.49e-4 -4.7518,2.129552 -4.7495,4.754267 2e-4,2.62286 2.1265,4.74679 4.7495,4.74695 2.6228,-1.6e-4 4.7492,-2.12409 4.7493,-4.74695 0,-2.624715 -2.1246,-4.754118 -4.7493,-4.754267 z" />
<g
id="g22"
transform="translate(-1219.8521)">
<path
id="path22"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#483737;fill-opacity:1;stroke-width:9.80478"
d="m 1464.5026,5 c -28.5907,0 -49.6505,20.826486 -49.6505,49.299551 0,28.473073 21.1791,49.297109 49.7699,49.297109 14.0881,0 26.3458,-5.056266 35.1585,-13.592646 8.8417,8.53638 21.1312,13.592646 35.2194,13.592646 28.5909,0 49.6506,-20.824036 49.6506,-49.297109 C 1584.6505,25.826486 1563.4739,5 1534.8832,5 1520.795,5 1508.5347,10.056275 1499.722,18.592651 1490.8804,10.056273 1478.5908,5 1464.5026,5 Z m 0,11.294718 c 11.3038,0 21.0508,4.346057 27.7603,11.69192 3.2744,3.584891 5.8258,7.884457 7.4811,12.742197 1.6471,-4.864244 4.1887,-9.169044 7.4542,-12.756817 6.6784,-7.337146 16.3885,-11.6773 27.685,-11.6773 21.5312,0 37.415,15.767597 37.415,38.004833 0,22.119593 -15.8844,38.002393 -37.2982,38.002393 -11.2943,0 -21.0343,-4.37015 -27.7432,-11.71873 -3.2869,-3.60007 -5.8476,-7.915358 -7.5056,-12.781188 -1.6544,4.859897 -4.2044,9.171568 -7.4786,12.769018 -6.6954,7.35651 -16.4112,11.7309 -27.6506,11.7309 -21.5314,0 -37.4152,-15.88281 -37.4152,-38.002393 0,-22.237236 15.7644,-38.004833 37.2958,-38.004833 z"
sodipodi:nodetypes="ssscssscssscssssscssss" />
<path
d="m 1463.5643,67.636337 c -2.6247,1.49e-4 -4.7519,2.129552 -4.7493,4.754267 10e-5,2.62286 2.1265,4.74679 4.7493,4.74695 2.623,-1.6e-4 4.7491,-2.12409 4.7495,-4.74695 0,-2.624715 -2.1248,-4.754118 -4.7495,-4.754267 z m 70.4489,0 c -2.6247,1.49e-4 -4.7518,2.129552 -4.7495,4.754267 2e-4,2.62286 2.1265,4.74679 4.7495,4.74695 2.6228,-1.6e-4 4.7492,-2.12409 4.7493,-4.74695 0,-2.624715 -2.1246,-4.754118 -4.7493,-4.754267 z"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#8a6f91;fill-opacity:1;stroke-width:9.80478"
id="path1-8" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -23,16 +23,16 @@
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
inkscape:zoom="2.1896304"
inkscape:cx="884.39583"
inkscape:cy="34.252356"
inkscape:zoom="2.8284271"
inkscape:cx="1382.9241"
inkscape:cy="89.095455"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g9"
showgrid="false">
showgrid="true">
<inkscape:page
x="0"
y="0"
@ -48,7 +48,10 @@
height="180"
id="page2"
margin="0"
bleed="0" />
bleed="0"
inkscape:export-filename="Goondex_LOGO2.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96" />
<inkscape:grid
id="grid3"
units="px"
@ -62,7 +65,23 @@
opacity="0.14901961"
empspacing="5"
enabled="true"
visible="false" />
visible="true" />
<inkscape:page
x="1220"
y="0"
width="180"
height="110"
id="page19"
margin="0"
bleed="0" />
<inkscape:page
x="1410"
y="0"
width="180"
height="110"
id="page20"
margin="0"
bleed="0" />
</sodipodi:namedview>
<defs
id="defs1" />
@ -139,6 +158,8 @@
id="path13"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';stroke-width:7.85855;fill:#ff5fa2;fill-opacity:1"
d="M 173.33789 50.472656 C 150.42237 50.472656 133.54297 67.165118 133.54297 89.986328 C 133.54297 112.80755 150.51808 129.49805 173.43359 129.49805 C 184.72527 129.49805 194.54994 125.44543 201.61328 118.60352 C 208.69985 125.44543 218.55013 129.49805 229.8418 129.49805 C 252.75733 129.49805 269.63672 112.80755 269.63672 89.986328 C 269.63672 67.165118 252.66358 50.472656 229.74805 50.472656 C 218.45638 50.472656 208.62975 54.525269 201.56641 61.367188 C 194.47983 54.525267 184.62956 50.472656 173.33789 50.472656 z M 173.33789 59.525391 C 182.39788 59.525391 190.21021 63.008763 195.58789 68.896484 C 198.21228 71.769779 200.25728 75.215888 201.58398 79.109375 C 202.9042 75.210676 204.94121 71.760371 207.55859 68.884766 C 212.91125 63.004031 220.69398 59.525391 229.74805 59.525391 C 247.00542 59.525391 259.73633 72.163146 259.73633 89.986328 C 259.73633 107.71522 247.00486 120.44531 229.8418 120.44531 C 220.78934 120.44531 212.98272 116.94263 207.60547 111.05273 C 204.97114 108.16726 202.91866 104.70856 201.58984 100.80859 C 200.2638 104.70381 198.21994 108.15962 195.5957 111.04297 C 190.22932 116.93923 182.44196 120.44531 173.43359 120.44531 C 156.17623 120.44531 143.44531 107.71522 143.44531 89.986328 C 143.44531 72.163146 156.08053 59.525391 173.33789 59.525391 z M 172.58594 100.67578 C 170.48224 100.6759 168.77728 102.38262 168.7793 104.48633 C 168.77939 106.58856 170.48372 108.2909 172.58594 108.29102 C 174.68815 108.2909 176.39244 106.58856 176.39258 104.48633 C 176.39458 102.38262 174.68964 100.6759 172.58594 100.67578 z M 229.05078 100.67578 C 226.94706 100.6759 225.24216 102.38262 225.24414 104.48633 C 225.24427 106.58856 226.94852 108.2909 229.05078 108.29102 C 231.153 108.2909 232.8573 106.58856 232.85742 104.48633 C 232.85942 102.38262 231.15448 100.6759 229.05078 100.67578 z " />
<g
id="g20">
<path
id="path19"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#483737;fill-opacity:1;stroke-width:7.85855"
@ -149,4 +170,18 @@
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#8a6f91;fill-opacity:1;stroke-width:7.85855"
id="path1-52" />
</g>
<path
id="path13-4"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#ff5fa2;fill-opacity:1;stroke-width:9.80478"
d="M 1274.6505,5 C 1246.0598,5 1225,25.826486 1225,54.299551 c 0,28.473073 21.1791,49.297109 49.7699,49.297109 14.0881,0 26.3458,-5.056266 35.1585,-13.592646 8.8417,8.53638 21.1312,13.592646 35.2194,13.592646 28.5909,0 49.6506,-20.824036 49.6506,-49.297109 C 1394.7984,25.826486 1373.6218,5 1345.0311,5 1330.9429,5 1318.6826,10.056275 1309.8699,18.592651 1301.0283,10.056273 1288.7387,5 1274.6505,5 Z m 0,11.294718 c 11.3038,0 21.0508,4.346057 27.7603,11.69192 3.2744,3.584891 5.8258,7.884457 7.4811,12.742197 1.6471,-4.864244 4.1887,-9.169044 7.4542,-12.756817 6.6784,-7.337146 16.3885,-11.6773 27.685,-11.6773 21.5312,0 37.415,15.767597 37.415,38.004833 0,22.119593 -15.8844,38.002393 -37.2982,38.002393 -11.2943,0 -21.0343,-4.37015 -27.7432,-11.71873 -3.2869,-3.60007 -5.8476,-7.915358 -7.5056,-12.781188 -1.6544,4.859897 -4.2044,9.171568 -7.4786,12.769018 -6.6954,7.35651 -16.4112,11.7309 -27.6506,11.7309 -21.5314,0 -37.4152,-15.88281 -37.4152,-38.002393 0,-22.237236 15.7644,-38.004833 37.2958,-38.004833 z m -0.9383,51.341619 c -2.6247,1.49e-4 -4.7519,2.129552 -4.7493,4.754267 10e-5,2.62286 2.1265,4.74679 4.7493,4.74695 2.623,-1.6e-4 4.7491,-2.12409 4.7495,-4.74695 0,-2.624715 -2.1248,-4.754118 -4.7495,-4.754267 z m 70.4489,0 c -2.6247,1.49e-4 -4.7518,2.129552 -4.7495,4.754267 2e-4,2.62286 2.1265,4.74679 4.7495,4.74695 2.6228,-1.6e-4 4.7492,-2.12409 4.7493,-4.74695 0,-2.624715 -2.1246,-4.754118 -4.7493,-4.754267 z" />
<path
id="path22"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#483737;fill-opacity:1;stroke-width:9.80478"
d="m 1464.5026,5 c -28.5907,0 -49.6505,20.826486 -49.6505,49.299551 0,28.473073 21.1791,49.297109 49.7699,49.297109 14.0881,0 26.3458,-5.056266 35.1585,-13.592646 8.8417,8.53638 21.1312,13.592646 35.2194,13.592646 28.5909,0 49.6506,-20.824036 49.6506,-49.297109 C 1584.6505,25.826486 1563.4739,5 1534.8832,5 1520.795,5 1508.5347,10.056275 1499.722,18.592651 1490.8804,10.056273 1478.5908,5 1464.5026,5 Z m 0,11.294718 c 11.3038,0 21.0508,4.346057 27.7603,11.69192 3.2744,3.584891 5.8258,7.884457 7.4811,12.742197 1.6471,-4.864244 4.1887,-9.169044 7.4542,-12.756817 6.6784,-7.337146 16.3885,-11.6773 27.685,-11.6773 21.5312,0 37.415,15.767597 37.415,38.004833 0,22.119593 -15.8844,38.002393 -37.2982,38.002393 -11.2943,0 -21.0343,-4.37015 -27.7432,-11.71873 -3.2869,-3.60007 -5.8476,-7.915358 -7.5056,-12.781188 -1.6544,4.859897 -4.2044,9.171568 -7.4786,12.769018 -6.6954,7.35651 -16.4112,11.7309 -27.6506,11.7309 -21.5314,0 -37.4152,-15.88281 -37.4152,-38.002393 0,-22.237236 15.7644,-38.004833 37.2958,-38.004833 z"
sodipodi:nodetypes="ssscssscssscssssscssss" />
<path
d="m 1463.5643,67.636337 c -2.6247,1.49e-4 -4.7519,2.129552 -4.7493,4.754267 10e-5,2.62286 2.1265,4.74679 4.7493,4.74695 2.623,-1.6e-4 4.7491,-2.12409 4.7495,-4.74695 0,-2.624715 -2.1248,-4.754118 -4.7495,-4.754267 z m 70.4489,0 c -2.6247,1.49e-4 -4.7518,2.129552 -4.7495,4.754267 2e-4,2.62286 2.1265,4.74679 4.7495,4.74695 2.6228,-1.6e-4 4.7492,-2.12409 4.7493,-4.74695 0,-2.624715 -2.1246,-4.754118 -4.7493,-4.754267 z"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#8a6f91;fill-opacity:1;stroke-width:9.80478"
id="path1-8" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -275,6 +275,106 @@ function copyToClipboard(text) {
});
}
// ============================================================================
// Adult Empire UI helpers
// ============================================================================
function setAEStatus(msg, isError = false) {
const el = document.getElementById('ae-status');
if (!el) return;
el.textContent = msg;
el.classList.toggle('error', !!isError);
el.style.display = msg ? 'block' : 'none';
}
async function aeImportPerformerByName() {
const name = prompt('Import performer by name (Adult Empire):');
if (!name) return;
setAEStatus(`Searching Adult Empire for "${name}"...`);
try {
const res = await fetch('/api/ae/import/performer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: name })
});
const result = await res.json();
if (result.success) {
setAEStatus(result.message);
setTimeout(() => location.reload(), 1500);
} else {
setAEStatus(result.message || 'Import failed', true);
}
} catch (err) {
setAEStatus(`Error: ${err.message}`, true);
}
}
async function aeImportPerformerByURL() {
const url = prompt('Paste Adult Empire performer URL:');
if (!url) return;
setAEStatus('Importing performer from Adult Empire URL...');
try {
const res = await fetch('/api/ae/import/performer-by-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const result = await res.json();
if (result.success) {
setAEStatus(result.message);
setTimeout(() => location.reload(), 1500);
} else {
setAEStatus(result.message || 'Import failed', true);
}
} catch (err) {
setAEStatus(`Error: ${err.message}`, true);
}
}
async function aeImportSceneByName() {
const title = prompt('Import scene by title (Adult Empire):');
if (!title) return;
setAEStatus(`Searching Adult Empire for "${title}"...`);
try {
const res = await fetch('/api/ae/import/scene', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: title })
});
const result = await res.json();
if (result.success) {
setAEStatus(result.message);
setTimeout(() => location.reload(), 1500);
} else {
setAEStatus(result.message || 'Import failed', true);
}
} catch (err) {
setAEStatus(`Error: ${err.message}`, true);
}
}
async function aeImportSceneByURL() {
const url = prompt('Paste Adult Empire scene URL:');
if (!url) return;
setAEStatus('Importing scene from Adult Empire URL...');
try {
const res = await fetch('/api/ae/import/scene-by-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const result = await res.json();
if (result.success) {
setAEStatus(result.message);
setTimeout(() => location.reload(), 1500);
} else {
setAEStatus(result.message || 'Import failed', true);
}
} catch (err) {
setAEStatus(`Error: ${err.message}`, true);
}
}
function applyFilters() {
// Hook for your search/filter logic
console.log("Applying filters…");
@ -396,7 +496,8 @@ async function importScene() {
}
async function syncAll() {
const force = document.getElementById('sync-force').checked;
const forceEl = document.getElementById('sync-force');
const force = forceEl ? forceEl.checked : false;
setImportStatus('sync', 'Syncing all data from TPDB...', false);
try {

View File

@ -93,16 +93,16 @@
<!-- HERO -->
<section class="hero-section">
<h1 class="hero-title">Welcome to Goondex</h1>
<p class="hero-subtitle">Adult Empire-first indexer (TPDB temporarily disabled)</p>
<p class="hero-subtitle">TPDB bulk imports with Adult Empire enrichment</p>
<div class="hero-actions">
<button class="btn" onclick="scrollToAEImport()">
Get Started (Adult Empire)
<button class="btn" onclick="bulkImportAll()">
TPDB Bulk Import
<div class="hoverEffect"><div></div></div>
</button>
<button class="btn-secondary" onclick="showTPDBDisabled()">
TPDB Sync (Disabled)
<button class="btn-secondary" onclick="syncAll()">
Sync Data
<div class="hoverEffect"><div></div></div>
</button>
</div>
@ -126,8 +126,8 @@
</div>
<div class="stat-actions">
<a href="/performers" class="stat-link">View all →</a>
<button class="btn-small" onclick="scrollToAEImport()">
Import via CLI
<button class="btn-small" onclick="aeImportPerformerByName()">
Quick import
<div class="hoverEffect"><div></div></div>
</button>
</div>
@ -142,10 +142,6 @@
</div>
<div class="stat-actions">
<a href="/studios" class="stat-link">View all →</a>
<button class="btn-small" onclick="scrollToAEImport()">
Import via CLI
<div class="hoverEffect"><div></div></div>
</button>
</div>
</div>
@ -158,8 +154,8 @@
</div>
<div class="stat-actions">
<a href="/scenes" class="stat-link">View all →</a>
<button class="btn-small" onclick="scrollToAEImport()">
Import via CLI
<button class="btn-small" onclick="aeImportSceneByName()">
Quick import
<div class="hoverEffect"><div></div></div>
</button>
</div>
@ -178,39 +174,71 @@
</div>
</section>
<!-- IMPORT SECTION -->
<!-- TPDB IMPORT/SYNC -->
<section class="import-section">
<h3 id="ae-import">Import from Adult Empire (CLI)</h3>
<h3 id="ae-import">TPDB Import & Sync</h3>
<p class="help-text">
TPDB bulk import is temporarily disabled. Use the Adult Empire CLI to seed your library with high-quality mainstream data:
Run bulk imports from TPDB, then enrich with AE/StashDB. Keep it running to build a complete base.
</p>
<div class="import-buttons">
<button class="btn-secondary" onclick="copyToClipboard('./goondex adultemp search-performer \"Name\"')">
Copy: Search Performer
<button class="btn" onclick="bulkImportAll()">
Import Everything (TPDB)
<div class="hoverEffect"><div></div></div>
</button>
<button class="btn-secondary" onclick="copyToClipboard('./goondex adultemp scrape-performer <url>')">
Copy: Import Performer
<button class="btn-secondary" onclick="bulkImportPerformers()">
Import All Performers
<div class="hoverEffect"><div></div></div>
</button>
<button class="btn-secondary" onclick="copyToClipboard('./goondex adultemp search-scene \"Title\"')">
Copy: Search Scene
<button class="btn-secondary" onclick="bulkImportStudios()">
Import All Studios
<div class="hoverEffect"><div></div></div>
</button>
<button class="btn-secondary" onclick="copyToClipboard('./goondex adultemp scrape-scene <url>')">
Copy: Import Scene
<button class="btn-secondary" onclick="bulkImportScenes()">
Import All Scenes
<div class="hoverEffect"><div></div></div>
</button>
<button class="btn-secondary" onclick="syncAll()">
Sync All
<div class="hoverEffect"><div></div></div>
</button>
</div>
<div id="import-all-import-status" class="status-banner" style="margin-top: 0.75rem;"></div>
<div id="sync-import-status" class="status-banner" style="margin-top: 0.75rem;"></div>
</section>
<!-- AE IMPORT SECTION -->
<section class="import-section">
<h3>Adult Empire Imports</h3>
<p class="help-text">
Import directly from Adult Empire via the UI with built-in progress feedback.
</p>
<div class="import-buttons">
<button class="btn-secondary" onclick="aeImportPerformerByName()">
Import Performer by Name
<div class="hoverEffect"><div></div></div>
</button>
<button class="btn-secondary" onclick="aeImportPerformerByURL()">
Import Performer by URL
<div class="hoverEffect"><div></div></div>
</button>
<button class="btn-secondary" onclick="aeImportSceneByName()">
Import Scene by Title
<div class="hoverEffect"><div></div></div>
</button>
<button class="btn-secondary" onclick="aeImportSceneByURL()">
Import Scene by URL
<div class="hoverEffect"><div></div></div>
</button>
</div>
<p class="help-text">
Movies: scraper not finished yet. Use scene/performer imports for now.
Movies: scraper not finished yet. Use performer/scene imports for now.
</p>
<p class="help-text">
Bulk “Import All” buttons will stay disabled until an Adult Empire bulk flow is available.
</p>
<div id="ae-status" class="status-banner"></div>
</section>
</main>

View File

@ -25,7 +25,7 @@
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container nav-inner">
<a class="navbar-brand d-flex align-items-center" href="/">
<img src="/static/img/logo/GOONDEX_logo.png" class="logo-img" alt="Goondex logo">
<img src="/static/img/logo/Goondex_LOGO.png" class="logo-img" alt="Goondex logo">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav"
@ -50,6 +50,9 @@
<li class="nav-item">
<a href="/movies" class="nav-link {{if eq .ActivePage "movies"}}active{{end}}">Movies</a>
</li>
<li class="nav-item">
<a href="/settings" class="nav-link {{if eq .ActivePage "settings"}}active{{end}}">Settings</a>
</li>
</ul>
</div>
</div>

View File

@ -66,17 +66,12 @@
<div class="empty-state">
<p>No performers found.</p>
<div class="empty-import-actions">
<p class="hint">Kick off a bulk import to seed your library.</p>
<p class="hint">Import performers from Adult Empire without the CLI.</p>
<div class="action-buttons">
<button type="button" class="btn" onclick="showTPDBDisabled()">Import all performers</button>
<button type="button" class="btn btn-secondary" onclick="showTPDBDisabled()">Import all studios</button>
<button type="button" class="btn btn-secondary" onclick="showTPDBDisabled()">Import all scenes</button>
<button type="button" class="btn btn-secondary" onclick="bulkImportMovies()">Import all movies</button>
<button type="button" class="btn" onclick="aeImportPerformerByName()">Import performer by name</button>
<button type="button" class="btn btn-secondary" onclick="aeImportPerformerByURL()">Import performer by URL</button>
</div>
<p class="hint">
Bulk import currently targets TPDB endpoints (disabled). Adult Empire bulk import will be added soon.
For Adult Empire now, use CLI to pick targets: <code>./goondex adultemp search-performer "Name"</code> then <code>./goondex adultemp scrape-performer &lt;url&gt;</code>.
</p>
<div id="ae-status" class="status-banner"></div>
</div>
</div>
{{end}}

View File

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "html-head" .}}
</head>
<body>
{{template "navbar" .}}
<main class="container">
<div class="page-header">
<h2>Settings</h2>
<p class="help-text">Manage API keys locally. Keys are stored in <code>config/api_keys.json</code> (gitignored).</p>
</div>
<div class="gx-card" style="margin-top: 1.5rem; padding: 1.5rem;">
<div class="form-grid">
<label class="form-label">TPDB API Key</label>
<input type="password" id="tpdb-key" class="input" placeholder="TPDB API key">
<label class="form-label">Adult Empire API Key</label>
<input type="password" id="ae-key" class="input" placeholder="Adult Empire API key (optional)">
<label class="form-label">StashDB API Key</label>
<input type="password" id="stashdb-key" class="input" placeholder="StashDB API key">
<label class="form-label">StashDB Endpoint</label>
<input type="text" id="stashdb-endpoint" class="input" placeholder="https://stashdb.org/graphql">
</div>
<div class="action-buttons" style="margin-top: 1rem;">
<button class="btn" onclick="saveApiKeys()">Save Keys<div class="hoverEffect"><div></div></div></button>
<button class="btn-secondary" onclick="loadApiKeys()">Reload<div class="hoverEffect"><div></div></div></button>
</div>
<div id="settings-status" class="status-banner" style="margin-top: 1rem;"></div>
</div>
</main>
{{template "html-scripts" .}}
<script>
async function loadApiKeys() {
setSettingsStatus('Loading...');
try {
const res = await fetch('/api/settings/api-keys');
const result = await res.json();
if (result.success && result.data) {
const d = result.data;
document.getElementById('tpdb-key').value = d.tpdb_api_key || '';
document.getElementById('ae-key').value = d.ae_api_key || '';
document.getElementById('stashdb-key').value = d.stashdb_api_key || '';
document.getElementById('stashdb-endpoint').value = d.stashdb_endpoint || 'https://stashdb.org/graphql';
setSettingsStatus('Loaded');
} else {
setSettingsStatus(result.message || 'Failed to load', true);
}
} catch (err) {
setSettingsStatus('Error: ' + err.message, true);
}
}
async function saveApiKeys() {
const payload = {
tpdb_api_key: document.getElementById('tpdb-key').value,
ae_api_key: document.getElementById('ae-key').value,
stashdb_api_key: document.getElementById('stashdb-key').value,
stashdb_endpoint: document.getElementById('stashdb-endpoint').value,
};
setSettingsStatus('Saving...');
try {
const res = await fetch('/api/settings/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = await res.json();
if (result.success) {
setSettingsStatus('Saved. Restart not required; new keys are active.');
} else {
setSettingsStatus(result.message || 'Failed to save', true);
}
} catch (err) {
setSettingsStatus('Error: ' + err.message, true);
}
}
function setSettingsStatus(msg, isError) {
const el = document.getElementById('settings-status');
el.textContent = msg;
el.classList.toggle('error', !!isError);
el.style.display = msg ? 'block' : 'none';
}
document.addEventListener('DOMContentLoaded', loadApiKeys);
</script>
</body>
</html>

22
scripts/load-env.sh Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Usage: source scripts/load-env.sh
# Loads API keys from .env.local (or .env if you prefer) without hardcoding them in scripts.
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
echo "Source this script: source scripts/load-env.sh" >&2
exit 1
fi
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -f "$ROOT/.env.local" ]]; then
set -a
source "$ROOT/.env.local"
set +a
elif [[ -f "$ROOT/.env" ]]; then
set -a
source "$ROOT/.env"
set +a
else
echo "No .env.local or .env found at $ROOT. Create one with your API keys." >&2
fi