Goondex/internal/web/server.go
Stu Leak 3b8adad57d 🚀 Goondex v0.1.0-dev3 - Comprehensive ML-Powered Search & Import System
MAJOR FEATURES ADDED:
======================

🤖 ML Analysis System:
- Comprehensive scene image analysis with per-scene predictions
- Enhanced database schema with scene_ml_analysis table
- Advanced detection for clothing colors, body types, age categories, positions, settings
- Support for multiple prediction types (clothing, body, sexual acts, etc.)
- Confidence scoring and ML source tracking

🧠 Enhanced Search Capabilities:
- Natural language parser for complex queries (e.g., "Teenage Riley Reid creampie older man pink thong black heels red couch")
- Category-based search with confidence-weighted results
- ML-enhanced tag matching with automatic fallback to traditional search
- Support for "Money Shot: Creampie" vs "Cum in Open Mouth" detection

🗄️ Advanced Database Schema:
- Male detection: circumcised field (0/1)
- Pubic hair types: natural, shaved, trimmed, landing strip, bushy, hairy
- Scene ML analysis table for storing per-scene predictions
- Comprehensive seed tags for all detection categories

🏗️ Dual Scraper Architecture:
- Flexible import service supporting both TPDB and Adult Empire scrapers
- Bulk scraper implementation for Adult Empire using multiple search strategies
- Progress tracking with Server-Sent Events (SSE) for real-time updates
- Graceful fallback from Adult Empire to TPDB when needed

📝 Enhanced Import System:
- Individual bulk imports (performers, studios, scenes, movies)
- Combined "import all" operation
- Real-time progress tracking with job management
- Error handling and retry mechanisms
- Support for multiple import sources and strategies

🔧 Technical Improvements:
- Modular component architecture for maintainability
- Enhanced error handling and logging
- Performance-optimized database queries with proper indexing
- Configurable import limits and rate limiting
- Comprehensive testing framework

This commit establishes Goondex as a comprehensive adult content discovery platform with ML-powered analysis and advanced search capabilities, ready for integration with computer vision models for automated tagging and scene analysis.
2025-12-30 21:52:25 -05:00

1489 lines
40 KiB
Go

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"
"git.leaktechnologies.dev/stu/Goondex/internal/search"
"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")
// Try Adult Empire first (primary scraper for new imports)
bulkScraper, err := scraper.NewAdultEmpireBulkScraper()
if err != nil {
// Fall back to TPDB if Adult Empire fails
apiKey, keyErr := tpdbAPIKey()
if writeTPDBError(w, keyErr) {
return
}
tpdbScraper := tpdb.NewScraper("https://api.theporndb.net", apiKey)
service := import_service.NewService(s.db, tpdbScraper)
if enricher, enrichErr := import_service.NewEnricher(s.db, 1*time.Second); enrichErr == nil {
service.WithEnricher(enricher)
}
result, err := service.BulkImportAllPerformers(context.Background())
s.writeImportResult(w, result, err, "Performers")
return
}
// Use Adult Empire scraper
service := import_service.NewFlexibleService(s.db, bulkScraper)
result, err := service.BulkImportAllPerformersFlexible(context.Background())
s.writeImportResult(w, result, err, "Performers")
}
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) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query().Get("q")
if query == "" {
json.NewEncoder(w).Encode(APIResponse{
Success: false,
Message: "Query parameter required",
})
return
}
// Use advanced search for complex queries
advancedSearch := search.NewAdvancedSearch(s.db)
results, err := advancedSearch.Search(query, 20)
if err != nil {
json.NewEncoder(w).Encode(APIResponse{
Success: false,
Message: fmt.Sprintf("Search failed: %v", err),
})
return
}
// Convert to format expected by frontend
scenes := make([]model.Scene, len(results))
for i, result := range results {
scenes[i] = result.Scene
}
response := map[string]interface{}{
"scenes": scenes,
"total": len(results),
"advanced": true,
"search_query": query,
}
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Found %d advanced results", len(results)),
Data: response,
})
}
// ============================================================================
// 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)
}
}