package web import ( "context" "embed" "encoding/json" "fmt" "html/template" "io/fs" "log" "net/http" "os" "strconv" "strings" "time" "git.leaktechnologies.dev/stu/Goondex/internal/config" "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/scraper/adultemp" "git.leaktechnologies.dev/stu/Goondex/internal/scraper/tpdb" "git.leaktechnologies.dev/stu/Goondex/internal/sync" ) // ============================================================================ // EMBED STATIC + TEMPLATES // ============================================================================ //go:embed templates/* static/**/* var content embed.FS type Server struct { db *db.DB templates *template.Template addr string dbPath string } func NewServer(database *db.DB, addr string, dbPath string) (*Server, error) { tmpl, err := template.ParseFS(content, "templates/*.html") if err != nil { return nil, fmt.Errorf("failed to parse templates: %w", err) } return &Server{ db: database, templates: tmpl, addr: addr, dbPath: dbPath, }, nil } func (s *Server) Start() error { mux := http.NewServeMux() // ============================================================================ // FIXED STATIC SERVER — THIS IS THE CORRECT WAY // ============================================================================ staticFS, err := fs.Sub(content, "static") if err != nil { return fmt.Errorf("failed to load embedded static directory: %w", err) } mux.Handle( "/static/", http.StripPrefix( "/static/", http.FileServer(http.FS(staticFS)), ), ) // ============================================================================ // ROUTES // ============================================================================ mux.HandleFunc("/", s.handleDashboard) mux.HandleFunc("/performers", s.handlePerformerList) mux.HandleFunc("/performers/", s.handlePerformerDetail) mux.HandleFunc("/studios", s.handleStudioList) mux.HandleFunc("/studios/", s.handleStudioDetail) mux.HandleFunc("/scenes", s.handleSceneList) 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) mux.HandleFunc("/api/settings/database", s.handleAPIDatabase) // API mux.HandleFunc("/api/import/performer", s.handleAPIImportPerformer) mux.HandleFunc("/api/import/studio", s.handleAPIImportStudio) mux.HandleFunc("/api/import/scene", s.handleAPIImportScene) mux.HandleFunc("/api/import/all", s.handleAPIBulkImportAll) mux.HandleFunc("/api/import/all-performers", s.handleAPIBulkImportPerformers) mux.HandleFunc("/api/import/all-studios", s.handleAPIBulkImportStudios) mux.HandleFunc("/api/import/all-scenes", s.handleAPIBulkImportScenes) mux.HandleFunc("/api/import/all-performers/progress", s.handleAPIBulkImportPerformersProgress) mux.HandleFunc("/api/import/all-studios/progress", s.handleAPIBulkImportStudiosProgress) mux.HandleFunc("/api/import/all-scenes/progress", s.handleAPIBulkImportScenesProgress) mux.HandleFunc("/api/sync", s.handleAPISync) mux.HandleFunc("/api/sync/status", s.handleAPISyncStatus) mux.HandleFunc("/api/search", s.handleAPIGlobalSearch) // ============================================================================ // START SERVER // ============================================================================ fmt.Printf("Starting Goondex Web Server at http://%s\n", s.addr) 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 // ============================================================================ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } performerStore := db.NewPerformerStore(s.db) studioStore := db.NewStudioStore(s.db) sceneStore := db.NewSceneStore(s.db) movieStore := db.NewMovieStore(s.db) performers, _ := performerStore.Search("") studios, _ := studioStore.Search("") scenes, _ := sceneStore.Search("") movies, _ := movieStore.Search("") data := map[string]interface{}{ "PageTitle": "Dashboard", "ActivePage": "dashboard", "PerformerCount": len(performers), "StudioCount": len(studios), "SceneCount": len(scenes), "MovieCount": len(movies), } s.render(w, "dashboard.html", data) } func (s *Server) handlePerformerList(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") nationalityFilter := r.URL.Query().Get("nationality") genderFilter := r.URL.Query().Get("gender") store := db.NewPerformerStore(s.db) performers, err := store.Search(query) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Apply filters var filteredPerformers []interface{} for _, p := range performers { // Filter by nationality if specified if nationalityFilter != "" && nationalityFilter != "all" { if p.Nationality != nationalityFilter { continue } } // Filter by gender if specified if genderFilter != "" && genderFilter != "all" { if p.Gender != genderFilter { continue } } filteredPerformers = append(filteredPerformers, p) } type PerformerWithCount struct { Performer interface{} SceneCount int Age int CountryFlag string } var performersWithCounts []PerformerWithCount for _, p := range filteredPerformers { performer := p.(model.Performer) count, _ := store.GetSceneCount(performer.ID) // Calculate age from birthday age := calculateAge(performer.Birthday) // Get country flag emoji countryFlag := getCountryFlag(performer.Nationality) performersWithCounts = append(performersWithCounts, PerformerWithCount{ Performer: performer, SceneCount: count, Age: age, CountryFlag: countryFlag, }) } // Get unique nationalities and genders for filter dropdowns nationalitiesMap := make(map[string]bool) gendersMap := make(map[string]bool) for _, p := range performers { if p.Nationality != "" { nationalitiesMap[p.Nationality] = true } if p.Gender != "" { gendersMap[p.Gender] = true } } var nationalities []string for nat := range nationalitiesMap { nationalities = append(nationalities, nat) } var genders []string for gender := range gendersMap { genders = append(genders, gender) } data := map[string]interface{}{ "PageTitle": "Performers", "ActivePage": "performers", "Performers": performersWithCounts, "Query": query, "Nationalities": nationalities, "Genders": genders, "SelectedNationality": nationalityFilter, "SelectedGender": genderFilter, } s.templates.ExecuteTemplate(w, "performers.html", data) } // calculateAge calculates age from birthday string (YYYY-MM-DD) func calculateAge(birthday string) int { if birthday == "" { return 0 } birthDate, err := time.Parse("2006-01-02", birthday) if err != nil { return 0 } now := time.Now() age := now.Year() - birthDate.Year() // Adjust if birthday hasn't occurred yet this year if now.Month() < birthDate.Month() || (now.Month() == birthDate.Month() && now.Day() < birthDate.Day()) { age-- } return age } // getCountryFlag converts ISO country code to flag emoji func getCountryFlag(countryCode string) string { if countryCode == "" { return "" } // Map of common ISO country codes to flag emojis // Unicode flags are formed by regional indicator symbols countryFlags := map[string]string{ "US": "🇺🇸", "GB": "🇬🇧", "CA": "🇨🇦", "AU": "🇦🇺", "FR": "🇫🇷", "DE": "🇩🇪", "IT": "🇮🇹", "ES": "🇪🇸", "RU": "🇷🇺", "JP": "🇯🇵", "CN": "🇨🇳", "BR": "🇧🇷", "MX": "🇲🇽", "AR": "🇦🇷", "CL": "🇨🇱", "CO": "🇨🇴", "CZ": "🇨🇿", "HU": "🇭🇺", "PL": "🇵🇱", "RO": "🇷🇴", "SK": "🇸🇰", "UA": "🇺🇦", "SE": "🇸🇪", "NO": "🇳🇴", "FI": "🇫🇮", "DK": "🇩🇰", "NL": "🇳🇱", "BE": "🇧🇪", "CH": "🇨🇭", "AT": "🇦🇹", "PT": "🇵🇹", "GR": "🇬🇷", "IE": "🇮🇪", "NZ": "🇳🇿", "ZA": "🇿🇦", "IN": "🇮🇳", "TH": "🇹🇭", "VN": "🇻🇳", "PH": "🇵🇭", "ID": "🇮🇩", "MY": "🇲🇾", "SG": "🇸🇬", "KR": "🇰🇷", "TW": "🇹🇼", "HK": "🇭🇰", "TR": "🇹🇷", "IL": "🇮🇱", "EG": "🇪🇬", } if flag, ok := countryFlags[countryCode]; ok { return flag } // For codes not in our map, try to convert programmatically // This works for any valid ISO 3166-1 alpha-2 code if len(countryCode) == 2 { code := []rune(countryCode) if code[0] >= 'A' && code[0] <= 'Z' && code[1] >= 'A' && code[1] <= 'Z' { // Regional indicator symbol base const regionalBase = 0x1F1E6 - 'A' return string([]rune{ rune(regionalBase + code[0]), rune(regionalBase + code[1]), }) } } return "🌍" // Default globe emoji } func (s *Server) handlePerformerDetail(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Path[len("/performers/"):] id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid performer ID", http.StatusBadRequest) return } performerStore := db.NewPerformerStore(s.db) sceneStore := db.NewSceneStore(s.db) performer, err := performerStore.GetByID(id) if err != nil { http.NotFound(w, r) return } sceneCount, _ := performerStore.GetSceneCount(id) scenes, _ := sceneStore.GetByPerformer(id) data := map[string]interface{}{ "PageTitle": fmt.Sprintf("Performer: %s", performer.Name), "ActivePage": "performers", "Stylesheets": []string{"/static/css/style.css", "/static/css/goondex.css"}, "Performer": performer, "SceneCount": sceneCount, "Scenes": scenes, } s.templates.ExecuteTemplate(w, "performer_detail.html", data) } func (s *Server) handleStudioList(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") store := db.NewStudioStore(s.db) studios, err := store.Search(query) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } type StudioWithCount struct { Studio interface{} SceneCount int } var studiosWithCounts []StudioWithCount for _, st := range studios { count, _ := store.GetSceneCount(st.ID) studiosWithCounts = append(studiosWithCounts, StudioWithCount{Studio: st, SceneCount: count}) } data := map[string]interface{}{ "PageTitle": "Studios", "ActivePage": "studios", "Studios": studiosWithCounts, "Query": query, } s.templates.ExecuteTemplate(w, "studios.html", data) } func (s *Server) handleStudioDetail(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Path[len("/studios/"):] id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid studio ID", http.StatusBadRequest) return } store := db.NewStudioStore(s.db) studio, err := store.GetByID(id) if err != nil { http.NotFound(w, r) return } sceneCount, _ := store.GetSceneCount(id) data := map[string]interface{}{ "PageTitle": fmt.Sprintf("Studio: %s", studio.Name), "ActivePage": "studios", "Stylesheets": []string{"/static/css/style.css", "/static/css/goondex.css"}, "Studio": studio, "SceneCount": sceneCount, } s.templates.ExecuteTemplate(w, "studio_detail.html", data) } func (s *Server) handleSceneList(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") sceneStore := db.NewSceneStore(s.db) studioStore := db.NewStudioStore(s.db) scenes, err := sceneStore.Search(query) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } type SceneWithStudio struct { Scene interface{} StudioName string } var scenesWithStudios []SceneWithStudio for _, sc := range scenes { studioName := "" if sc.StudioID != nil && *sc.StudioID > 0 { if studio, err := studioStore.GetByID(*sc.StudioID); err == nil { studioName = studio.Name } } scenesWithStudios = append(scenesWithStudios, SceneWithStudio{Scene: sc, StudioName: studioName}) } data := map[string]interface{}{ "PageTitle": "Scenes", "ActivePage": "scenes", "Scenes": scenesWithStudios, "Query": query, } s.templates.ExecuteTemplate(w, "scenes.html", data) } func (s *Server) handleSceneDetail(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Path[len("/scenes/"):] id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid scene ID", http.StatusBadRequest) return } sceneStore := db.NewSceneStore(s.db) studioStore := db.NewStudioStore(s.db) scene, err := sceneStore.GetByID(id) if err != nil { http.NotFound(w, r) return } performers, _ := sceneStore.GetPerformers(id) tags, _ := sceneStore.GetTags(id) movies, _ := sceneStore.GetMovies(id) studioName := "" if scene.StudioID != nil && *scene.StudioID > 0 { if studio, err := studioStore.GetByID(*scene.StudioID); err == nil { studioName = studio.Name } } data := map[string]interface{}{ "PageTitle": fmt.Sprintf("Scene: %s", scene.Title), "ActivePage": "scenes", "Stylesheets": []string{"/static/css/style.css", "/static/css/goondex.css"}, "Scene": scene, "Performers": performers, "Tags": tags, "Movies": movies, "StudioName": studioName, } s.templates.ExecuteTemplate(w, "scene_detail.html", data) } func (s *Server) handleMovieList(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") movieStore := db.NewMovieStore(s.db) studioStore := db.NewStudioStore(s.db) movies, err := movieStore.Search(query) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } type MovieWithDetails struct { Movie interface{} StudioName string SceneCount int } var moviesWithDetails []MovieWithDetails for _, m := range movies { studioName := "" if m.StudioID != nil && *m.StudioID > 0 { if studio, err := studioStore.GetByID(*m.StudioID); err == nil { studioName = studio.Name } } sceneCount, _ := movieStore.GetSceneCount(m.ID) moviesWithDetails = append(moviesWithDetails, MovieWithDetails{Movie: m, StudioName: studioName, SceneCount: sceneCount}) } data := map[string]interface{}{ "PageTitle": "Movies", "ActivePage": "movies", "Movies": moviesWithDetails, "Query": query, } s.templates.ExecuteTemplate(w, "movies.html", data) } func (s *Server) handleMovieDetail(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Path[len("/movies/"):] id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid movie ID", http.StatusBadRequest) return } movieStore := db.NewMovieStore(s.db) studioStore := db.NewStudioStore(s.db) movie, err := movieStore.GetByID(id) if err != nil { http.NotFound(w, r) return } scenes, _ := movieStore.GetScenes(id) studioName := "" if movie.StudioID != nil && *movie.StudioID > 0 { if studio, err := studioStore.GetByID(*movie.StudioID); err == nil { studioName = studio.Name } } data := map[string]interface{}{ "PageTitle": fmt.Sprintf("Movie: %s", movie.Title), "ActivePage": "movies", "Movie": movie, "Scenes": scenes, "StudioName": studioName, } s.templates.ExecuteTemplate(w, "movie_detail.html", data) } // ============================================================================ // API HANDLERS (NO LOGIC CHANGES — ONLY STATIC FIXES ABOVE) // ============================================================================ type APIResponse struct { Success bool `json:"success"` Message string `json:"message"` Data interface{} `json:"data,omitempty"` } 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) { if !tpdbEnabled { return "", fmt.Errorf(tpdbDisabledMessage) } apiKey := config.GetAPIKeys().TPDBAPIKey if apiKey == "" { return "", fmt.Errorf("TPDB_API_KEY not configured") } return apiKey, nil } func writeTPDBError(w http.ResponseWriter, err error) bool { if err == nil { return false } json.NewEncoder(w).Encode(APIResponse{Success: false, Message: err.Error()}) return true } func (s *Server) handleAPIImportPerformer(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 { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: "Invalid request"}) return } apiKey, err := tpdbAPIKey() if writeTPDBError(w, err) { return } scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey) performers, err := scraper.SearchPerformers(context.Background(), req.Query) if err != nil { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("Search failed: %v", err)}) return } if len(performers) == 0 { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: "No performers found"}) return } store := db.NewPerformerStore(s.db) imported := 0 for _, p := range performers { fullPerformer, err := scraper.GetPerformerByID(context.Background(), p.SourceID) if err != nil { fullPerformer = &p } if err := store.Create(fullPerformer); err != nil { continue } imported++ } json.NewEncoder(w).Encode(APIResponse{ Success: true, Message: fmt.Sprintf("Imported %d performer(s)", imported), Data: map[string]int{"imported": imported, "found": len(performers)}, }) } func (s *Server) handleAPIImportStudio(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 { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: "Invalid request"}) return } apiKey, err := tpdbAPIKey() if writeTPDBError(w, err) { return } scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey) studios, err := scraper.SearchStudios(context.Background(), req.Query) if err != nil { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("Search failed: %v", err)}) return } if len(studios) == 0 { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: "No studios found"}) return } store := db.NewStudioStore(s.db) imported := 0 for _, st := range studios { if err := store.Create(&st); err != nil { continue } imported++ } json.NewEncoder(w).Encode(APIResponse{ Success: true, Message: fmt.Sprintf("Imported %d studio(s)", imported), Data: map[string]int{"imported": imported, "found": len(studios)}, }) } func (s *Server) handleAPIImportScene(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 { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: "Invalid request"}) return } apiKey, err := tpdbAPIKey() if writeTPDBError(w, err) { return } scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey) scenes, err := scraper.SearchScenes(context.Background(), req.Query) if err != nil { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("Search failed: %v", err)}) return } if len(scenes) == 0 { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: "No scenes found"}) return } sceneStore := db.NewSceneStore(s.db) performerStore := db.NewPerformerStore(s.db) studioStore := db.NewStudioStore(s.db) tagStore := db.NewTagStore(s.db) imported := 0 for _, sc := range scenes { if sc.Studio != nil { if err := studioStore.Create(sc.Studio); err != nil { studios, _ := studioStore.Search(sc.Studio.Name) if len(studios) > 0 { sc.StudioID = &studios[0].ID } } else { sc.StudioID = &sc.Studio.ID } } if err := sceneStore.Create(&sc); err != nil { continue } for _, p := range sc.Performers { if err := performerStore.Create(&p); err != nil { performers, _ := performerStore.Search(p.Name) if len(performers) > 0 { p.ID = performers[0].ID } } if p.ID > 0 { sceneStore.AddPerformer(sc.ID, p.ID) } } for _, t := range sc.Tags { existing, _ := tagStore.GetByName(t.Name) if existing != nil { t.ID = existing.ID } else { if err := tagStore.Create(&t); err != nil { continue } } if t.ID > 0 { sceneStore.AddTag(sc.ID, t.ID) } } imported++ } json.NewEncoder(w).Encode(APIResponse{ Success: true, Message: fmt.Sprintf("Imported %d scene(s)", imported), Data: map[string]int{"imported": imported, "found": len(scenes)}, }) } func (s *Server) handleAPISync(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req struct { Force bool `json:"force"` } json.NewDecoder(r.Body).Decode(&req) apiKey, err := tpdbAPIKey() if writeTPDBError(w, err) { return } scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey) service := sync.NewService(s.db, scraper) opts := sync.SyncOptions{ Force: req.Force, MinInterval: 24 * time.Hour, } results, err := service.SyncAll(context.Background(), opts) if err != nil { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("Sync failed: %v", err)}) return } json.NewEncoder(w).Encode(APIResponse{ Success: true, Message: "Sync completed", Data: results, }) } func (s *Server) handleAPISyncStatus(w http.ResponseWriter, r *http.Request) { if writeTPDBError(w, fmt.Errorf(tpdbDisabledMessage)) { return } scraper := tpdb.NewScraper("https://api.theporndb.net", "") service := sync.NewService(s.db, scraper) statuses, err := service.GetSyncStatus() if err != nil { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("Failed to get status: %v", err)}) return } json.NewEncoder(w).Encode(APIResponse{ Success: true, Message: "Status retrieved", Data: statuses, }) } // ============================================================================ // 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 // ============================================================================ func (s *Server) handleAPIBulkImportAll(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") apiKey, err := tpdbAPIKey() if writeTPDBError(w, err) { return } scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey) service := import_service.NewService(s.db, scraper) results, err := service.BulkImportAll(context.Background()) if err != nil { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("Bulk import failed: %v", err)}) return } json.NewEncoder(w).Encode(APIResponse{ Success: true, Message: "Bulk import completed successfully", Data: results, }) } func (s *Server) handleAPIBulkImportPerformers(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") apiKey, err := tpdbAPIKey() if writeTPDBError(w, err) { return } scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey) service := import_service.NewService(s.db, scraper) if enricher, err := import_service.NewEnricher(s.db, 1*time.Second); err == nil { service.WithEnricher(enricher) } result, err := service.BulkImportAllPerformers(context.Background()) if err != nil { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("Import failed: %v", err)}) return } json.NewEncoder(w).Encode(APIResponse{ Success: true, Message: fmt.Sprintf("Imported %d/%d performers", result.Imported, result.Total), Data: result, }) } func (s *Server) handleAPIBulkImportStudios(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") apiKey, err := tpdbAPIKey() if writeTPDBError(w, err) { return } scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey) service := import_service.NewService(s.db, scraper) result, err := service.BulkImportAllStudios(context.Background()) if err != nil { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("Import failed: %v", err)}) return } json.NewEncoder(w).Encode(APIResponse{ Success: true, Message: fmt.Sprintf("Imported %d/%d studios", result.Imported, result.Total), Data: result, }) } func (s *Server) handleAPIBulkImportScenes(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") apiKey, err := tpdbAPIKey() if writeTPDBError(w, err) { return } scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey) service := import_service.NewService(s.db, scraper) result, err := service.BulkImportAllScenes(context.Background()) if err != nil { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("Import failed: %v", err)}) return } json.NewEncoder(w).Encode(APIResponse{ Success: true, Message: fmt.Sprintf("Imported %d/%d scenes", result.Imported, result.Total), Data: result, }) } func (s *Server) handleAPIBulkImportPerformersProgress(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") apiKey, err := tpdbAPIKey() if err != nil { fmt.Fprintf(w, "data: {\"error\": \"%s\"}\n\n", err.Error()) return } scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey) service := import_service.NewService(s.db, scraper) flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "Streaming unsupported", http.StatusInternalServerError) return } progressCallback := func(update import_service.ProgressUpdate) { data, _ := json.Marshal(update) fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() } result, err := service.BulkImportAllPerformersWithProgress( context.Background(), progressCallback) if err != nil { fmt.Fprintf(w, "data: {\"error\": \"%s\"}\n\n", err.Error()) } else { data, _ := json.Marshal(map[string]interface{}{ "complete": true, "result": result, }) fmt.Fprintf(w, "data: %s\n\n", data) } flusher.Flush() } func (s *Server) handleAPIBulkImportStudiosProgress(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") apiKey, err := tpdbAPIKey() if err != nil { fmt.Fprintf(w, "data: {\"error\": \"%s\"}\n\n", err.Error()) return } scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey) service := import_service.NewService(s.db, scraper) flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "Streaming unsupported", http.StatusInternalServerError) return } progressCallback := func(update import_service.ProgressUpdate) { data, _ := json.Marshal(update) fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() } result, err := service.BulkImportAllStudiosWithProgress( context.Background(), progressCallback) if err != nil { fmt.Fprintf(w, "data: {\"error\": \"%s\"}\n\n", err.Error()) } else { data, _ := json.Marshal(map[string]interface{}{ "complete": true, "result": result, }) fmt.Fprintf(w, "data: %s\n\n", data) } flusher.Flush() } func (s *Server) handleAPIBulkImportScenesProgress(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") apiKey, err := tpdbAPIKey() if err != nil { fmt.Fprintf(w, "data: {\"error\": \"%s\"}\n\n", err.Error()) return } scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey) service := import_service.NewService(s.db, scraper) flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "Streaming unsupported", http.StatusInternalServerError) return } progressCallback := func(update import_service.ProgressUpdate) { data, _ := json.Marshal(update) fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() } result, err := service.BulkImportAllScenesWithProgress( context.Background(), progressCallback) if err != nil { fmt.Fprintf(w, "data: {\"error\": \"%s\"}\n\n", err.Error()) } else { data, _ := json.Marshal(map[string]interface{}{ "complete": true, "result": result, }) fmt.Fprintf(w, "data: %s\n\n", data) } flusher.Flush() } // ============================================================================ // GLOBAL SEARCH // ============================================================================ func (s *Server) handleAPIGlobalSearch(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") if query == "" { json.NewEncoder(w).Encode(APIResponse{ Success: false, Message: "Query parameter required", }) return } performerStore := db.NewPerformerStore(s.db) studioStore := db.NewStudioStore(s.db) sceneStore := db.NewSceneStore(s.db) tagStore := db.NewTagStore(s.db) performers, _ := performerStore.Search(query) studios, _ := studioStore.Search(query) scenes, _ := sceneStore.Search(query) tags, _ := tagStore.Search(query) results := map[string]interface{}{ "performers": performers, "studios": studios, "scenes": scenes, "tags": tags, "total": len(performers) + len(studios) + len(scenes) + len(tags), } json.NewEncoder(w).Encode(APIResponse{ Success: true, Message: fmt.Sprintf("Found %d results", results["total"]), 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", "DBPath": s.dbPath, } 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) } } // Database management func (s *Server) handleAPIDatabase(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: info := map[string]interface{}{ "path": s.dbPath, } if stat, err := os.Stat(s.dbPath); err == nil { info["size_bytes"] = stat.Size() info["size_mb"] = float64(stat.Size()) / (1024 * 1024) } json.NewEncoder(w).Encode(APIResponse{ Success: true, Message: "OK", Data: info, }) case http.MethodDelete: // Close and recreate if s.db != nil { _ = s.db.Close() } _ = os.Remove(s.dbPath) newDB, err := db.Open(s.dbPath) if err != nil { json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("Failed to recreate DB: %v", err)}) return } s.db = newDB json.NewEncoder(w).Encode(APIResponse{ Success: true, Message: "Database deleted and recreated.", }) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } }