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.
1489 lines
40 KiB
Go
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)
|
|
}
|
|
}
|