Goondex/cmd/goondex/main.go
Team Goon d9048db660 v0.1.0-dev3: Complete TPDB metadata with duplicate prevention
This release adds comprehensive metadata support and fixes the duplicate
performer issue.

MAJOR FIXES:
 Duplicate Prevention
   - Added UNIQUE(source, source_id) constraint to performers table
   - ON CONFLICT DO UPDATE in performer store
   - No more duplicate Riley Reid entries!

 Comprehensive TPDB Metadata
   - Extended Performer model with ALL TPDB fields
   - Physical: height, weight, measurements, cup size, eye/hair color
   - Personal: birthday, astrology, birthplace, ethnicity, nationality
   - Body: tattoos, piercings, boob job status
   - Career: start/end years, active status
   - Added PerformerExtras nested struct for TPDB "extras" object
   - Parse weight/height strings ("49kg" -> 49, "160cm" -> 160)
   - Handle British spelling (hair_colour, eye_colour)

 Enriched Import
   - Auto-fetch full performer details via GetPerformerByID
   - Search results now enriched with complete metadata
   - UUID + numeric TPDB ID both stored

 Enhanced CLI Output
   - Formatted display with all available stats
   - Height shown in cm and feet/inches
   - Weight shown in kg and lbs
   - Organized sections (IDs, Personal, Physical, Bio, Media)
   - Beautiful separator bars

TECHNICAL DETAILS:
- Schema: 25+ new performer fields with proper types
- Types: PerformerExtras struct for nested TPDB response
- Mapper: String parsing for "160cm", "49kg" format
- Store: Full field support in Create/Search/GetByID
- Display: Conditional rendering of all available data

TESTING:
 Riley Reid import: All 25+ fields populated correctly
 Duplicate prevention: Second import updates existing record
 Broad search ("riley"): Only 2 unique performers
 Data accuracy: Matches theporndb.net/performers/riley-reid

Database now captures:
- UUID: 26d101c0-1e23-4e1f-ac12-8c30e0e2f451
- TPDB ID: 83047
- Birthday: 1991-07-09
- Height: 160cm (5'3")
- Weight: 49kg (108lb)
- Measurements: 32A-24-34
- All tattoos, piercings, career info, and full bio

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 11:50:40 -05:00

627 lines
16 KiB
Go

package main
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"git.leaktechnologies.dev/stu/Goondex/internal/db"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper/tpdb"
)
var (
dbPath string
rootCmd = &cobra.Command{
Use: "goondex",
Short: "Goondex - Fast, local-first media indexer",
Long: `Goondex is a fast, local-first media indexer for adult content that ingests metadata from external sources.`,
}
)
func init() {
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "./goondex.db", "Path to SQLite database")
// Add subcommands
rootCmd.AddCommand(performerSearchCmd)
rootCmd.AddCommand(studioSearchCmd)
rootCmd.AddCommand(sceneSearchCmd)
rootCmd.AddCommand(importCmd)
rootCmd.AddCommand(versionCmd)
}
// Import command with subcommands
var importCmd = &cobra.Command{
Use: "import",
Short: "Import data from external sources (TPDB)",
Long: `Import performers, studios, and scenes from ThePornDB into your local database.`,
}
func init() {
importCmd.AddCommand(importPerformerCmd)
importCmd.AddCommand(importStudioCmd)
importCmd.AddCommand(importSceneCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func getDB() (*db.DB, error) {
database, err := db.Open(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
return database, nil
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Goondex v0.1.0-dev2")
},
}
var performerSearchCmd = &cobra.Command{
Use: "performer-search [query]",
Short: "Search for performers (auto-fetches from TPDB if not in local database)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
query := args[0]
database, err := getDB()
if err != nil {
return err
}
defer database.Close()
store := db.NewPerformerStore(database)
performers, err := store.Search(query)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
// If no local results, try fetching from TPDB
if len(performers) == 0 {
fmt.Printf("No local results found. Searching TPDB for '%s'...\n", query)
apiKey := os.Getenv("TPDB_API_KEY")
if apiKey == "" {
fmt.Println("⚠ TPDB_API_KEY not set. Cannot fetch from TPDB.")
fmt.Println("Set it with: export TPDB_API_KEY=\"your-key\"")
return nil
}
scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey)
tpdbPerformers, err := scraper.SearchPerformers(context.Background(), query)
if err != nil {
fmt.Printf("⚠ TPDB search failed: %v\n", err)
return nil
}
if len(tpdbPerformers) == 0 {
fmt.Println("No performers found on TPDB either.")
return nil
}
fmt.Printf("Found %d performer(s) on TPDB. Fetching full details...\n\n", len(tpdbPerformers))
// Import from TPDB with enriched data
imported := 0
for _, p := range tpdbPerformers {
// Fetch full details for this performer
fullPerformer, err := scraper.GetPerformerByID(context.Background(), p.SourceID)
if err != nil {
fmt.Printf("⚠ Failed to fetch details for %s: %v\n", p.Name, err)
// Fall back to search result
fullPerformer = &p
}
if err := store.Create(fullPerformer); err != nil {
fmt.Printf("⚠ Failed to import %s: %v\n", fullPerformer.Name, err)
continue
}
imported++
}
// Search again to get the imported performers with their IDs
performers, err = store.Search(query)
if err != nil {
return fmt.Errorf("search failed after import: %w", err)
}
fmt.Printf("✓ Imported %d performer(s)\n\n", imported)
}
fmt.Printf("Found %d performer(s):\n\n", len(performers))
for _, p := range performers {
fmt.Printf("═══════════════════════════════════════════════════\n")
fmt.Printf("Name: %s\n", p.Name)
if p.Aliases != "" {
fmt.Printf("Aliases: %s\n", p.Aliases)
}
fmt.Printf("\n")
// IDs
fmt.Printf("Local ID: %d\n", p.ID)
if p.Source != "" {
fmt.Printf("Source: %s\n", p.Source)
fmt.Printf("UUID: %s\n", p.SourceID)
if p.SourceNumericID > 0 {
fmt.Printf("TPDB ID: %d\n", p.SourceNumericID)
}
}
fmt.Printf("\n")
// Personal info
if p.Gender != "" {
fmt.Printf("Gender: %s\n", p.Gender)
}
if p.Birthday != "" {
fmt.Printf("Birthday: %s\n", p.Birthday)
}
if p.Astrology != "" {
fmt.Printf("Astrology: %s\n", p.Astrology)
}
if p.DateOfDeath != "" {
fmt.Printf("Date of Death: %s\n", p.DateOfDeath)
}
if p.Career != "" {
fmt.Printf("Career: %s\n", p.Career)
}
if p.Birthplace != "" {
fmt.Printf("Birthplace: %s\n", p.Birthplace)
}
if p.Ethnicity != "" {
fmt.Printf("Ethnicity: %s\n", p.Ethnicity)
}
if p.Nationality != "" {
fmt.Printf("Nationality: %s\n", p.Nationality)
}
fmt.Printf("\n")
// Physical attributes
if p.CupSize != "" {
fmt.Printf("Cup Size: %s\n", p.CupSize)
}
if p.HairColor != "" {
fmt.Printf("Hair Colour: %s\n", p.HairColor)
}
if p.EyeColor != "" {
fmt.Printf("Eye Colour: %s\n", p.EyeColor)
}
if p.Height > 0 {
feet := float64(p.Height) / 30.48
inches := (float64(p.Height) / 2.54) - (feet * 12)
fmt.Printf("Height: %dcm (%.0f'%.0f\")\n", p.Height, feet, inches)
}
if p.Weight > 0 {
lbs := float64(p.Weight) * 2.20462
fmt.Printf("Weight: %dkg (%.0flb)\n", p.Weight, lbs)
}
if p.Measurements != "" {
fmt.Printf("Measurements: %s\n", p.Measurements)
}
if p.TattooDescription != "" {
fmt.Printf("Tattoos: %s\n", p.TattooDescription)
}
if p.PiercingDescription != "" {
fmt.Printf("Piercings: %s\n", p.PiercingDescription)
}
if p.BoobJob != "" {
fmt.Printf("Fake Boobs: %s\n", p.BoobJob)
}
fmt.Printf("\n")
// Bio
if p.Bio != "" {
fmt.Printf("Bio:\n%s\n\n", p.Bio)
}
// Media
if p.ImageURL != "" {
fmt.Printf("Image: %s\n", p.ImageURL)
}
if p.PosterURL != "" {
fmt.Printf("Poster: %s\n", p.PosterURL)
}
fmt.Printf("═══════════════════════════════════════════════════\n\n")
}
return nil
},
}
var studioSearchCmd = &cobra.Command{
Use: "studio-search [query]",
Short: "Search for studios (auto-fetches from TPDB if not in local database)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
query := args[0]
database, err := getDB()
if err != nil {
return err
}
defer database.Close()
store := db.NewStudioStore(database)
studios, err := store.Search(query)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
// If no local results, try fetching from TPDB
if len(studios) == 0 {
fmt.Printf("No local results found. Searching TPDB for '%s'...\n", query)
apiKey := os.Getenv("TPDB_API_KEY")
if apiKey == "" {
fmt.Println("⚠ TPDB_API_KEY not set. Cannot fetch from TPDB.")
fmt.Println("Set it with: export TPDB_API_KEY=\"your-key\"")
return nil
}
scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey)
tpdbStudios, err := scraper.SearchStudios(context.Background(), query)
if err != nil {
fmt.Printf("⚠ TPDB search failed: %v\n", err)
return nil
}
if len(tpdbStudios) == 0 {
fmt.Println("No studios found on TPDB either.")
return nil
}
fmt.Printf("Found %d studio(s) on TPDB. Importing...\n\n", len(tpdbStudios))
// Import from TPDB
imported := 0
for _, s := range tpdbStudios {
if err := store.Create(&s); err != nil {
fmt.Printf("⚠ Failed to import %s: %v\n", s.Name, err)
continue
}
imported++
}
// Search again to get the imported studios with their IDs
studios, err = store.Search(query)
if err != nil {
return fmt.Errorf("search failed after import: %w", err)
}
fmt.Printf("✓ Imported %d studio(s)\n\n", imported)
}
fmt.Printf("Found %d studio(s):\n\n", len(studios))
for _, s := range studios {
fmt.Printf("ID: %d\n", s.ID)
fmt.Printf("Name: %s\n", s.Name)
if s.Description != "" {
fmt.Printf("Description: %s\n", s.Description)
}
if s.Source != "" {
fmt.Printf("Source: %s (ID: %s)\n", s.Source, s.SourceID)
}
fmt.Println("---")
}
return nil
},
}
var sceneSearchCmd = &cobra.Command{
Use: "scene-search [query]",
Short: "Search for scenes (auto-fetches from TPDB if not in local database)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
query := args[0]
database, err := getDB()
if err != nil {
return err
}
defer database.Close()
store := db.NewSceneStore(database)
scenes, err := store.Search(query)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
// If no local results, try fetching from TPDB
if len(scenes) == 0 {
fmt.Printf("No local results found. Searching TPDB for '%s'...\n", query)
apiKey := os.Getenv("TPDB_API_KEY")
if apiKey == "" {
fmt.Println("⚠ TPDB_API_KEY not set. Cannot fetch from TPDB.")
fmt.Println("Set it with: export TPDB_API_KEY=\"your-key\"")
return nil
}
scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey)
tpdbScenes, err := scraper.SearchScenes(context.Background(), query)
if err != nil {
fmt.Printf("⚠ TPDB search failed: %v\n", err)
return nil
}
if len(tpdbScenes) == 0 {
fmt.Println("No scenes found on TPDB either.")
return nil
}
fmt.Printf("Found %d scene(s) on TPDB. Importing (basic metadata only)...\n\n", len(tpdbScenes))
// Import scenes (simplified - just scene metadata, no relationships)
imported := 0
for _, sc := range tpdbScenes {
// Clear relationships to avoid complexity in auto-import
sc.Performers = nil
sc.Tags = nil
sc.Studio = nil
sc.StudioID = nil
if err := store.Create(&sc); err != nil {
fmt.Printf("⚠ Failed to import %s: %v\n", sc.Title, err)
continue
}
imported++
}
// Search again to get the imported scenes with their IDs
scenes, err = store.Search(query)
if err != nil {
return fmt.Errorf("search failed after import: %w", err)
}
fmt.Printf("✓ Imported %d scene(s) (use 'import scene' for full metadata with relationships)\n\n", imported)
}
fmt.Printf("Found %d scene(s):\n\n", len(scenes))
for _, sc := range scenes {
fmt.Printf("ID: %d\n", sc.ID)
fmt.Printf("Title: %s\n", sc.Title)
if sc.Code != "" {
fmt.Printf("Code: %s\n", sc.Code)
}
if sc.Date != "" {
fmt.Printf("Date: %s\n", sc.Date)
}
if sc.Description != "" {
fmt.Printf("Description: %s\n", sc.Description)
}
if sc.Source != "" {
fmt.Printf("Source: %s (ID: %s)\n", sc.Source, sc.SourceID)
}
fmt.Println("---")
}
return nil
},
}
var importPerformerCmd = &cobra.Command{
Use: "performer [query]",
Short: "Search TPDB for performers and import them to local database",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
query := args[0]
// Get API key from environment
apiKey := os.Getenv("TPDB_API_KEY")
if apiKey == "" {
return fmt.Errorf("TPDB_API_KEY environment variable is not set")
}
// Create TPDB scraper
scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey)
// Search TPDB
fmt.Printf("Searching TPDB for performers matching '%s'...\n", query)
performers, err := scraper.SearchPerformers(context.Background(), query)
if err != nil {
return fmt.Errorf("failed to search TPDB: %w", err)
}
if len(performers) == 0 {
fmt.Println("No performers found on TPDB")
return nil
}
fmt.Printf("Found %d performer(s) on TPDB\n\n", len(performers))
// Open database
database, err := getDB()
if err != nil {
return err
}
defer database.Close()
store := db.NewPerformerStore(database)
// Import each performer
imported := 0
for _, p := range performers {
fmt.Printf("Importing: %s (TPDB ID: %s)\n", p.Name, p.SourceID)
if err := store.Create(&p); err != nil {
fmt.Printf(" ⚠ Warning: Failed to import: %v\n", err)
continue
}
fmt.Printf(" ✓ Imported with local ID: %d\n", p.ID)
imported++
}
fmt.Printf("\n✓ Successfully imported %d/%d performers\n", imported, len(performers))
return nil
},
}
var importStudioCmd = &cobra.Command{
Use: "studio [query]",
Short: "Search TPDB for studios and import them to local database",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
query := args[0]
// Get API key from environment
apiKey := os.Getenv("TPDB_API_KEY")
if apiKey == "" {
return fmt.Errorf("TPDB_API_KEY environment variable is not set")
}
// Create TPDB scraper
scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey)
// Search TPDB
fmt.Printf("Searching TPDB for studios matching '%s'...\n", query)
studios, err := scraper.SearchStudios(context.Background(), query)
if err != nil {
return fmt.Errorf("failed to search TPDB: %w", err)
}
if len(studios) == 0 {
fmt.Println("No studios found on TPDB")
return nil
}
fmt.Printf("Found %d studio(s) on TPDB\n\n", len(studios))
// Open database
database, err := getDB()
if err != nil {
return err
}
defer database.Close()
store := db.NewStudioStore(database)
// Import each studio
imported := 0
for _, s := range studios {
fmt.Printf("Importing: %s (TPDB ID: %s)\n", s.Name, s.SourceID)
if err := store.Create(&s); err != nil {
fmt.Printf(" ⚠ Warning: Failed to import: %v\n", err)
continue
}
fmt.Printf(" ✓ Imported with local ID: %d\n", s.ID)
imported++
}
fmt.Printf("\n✓ Successfully imported %d/%d studios\n", imported, len(studios))
return nil
},
}
var importSceneCmd = &cobra.Command{
Use: "scene [query]",
Short: "Search TPDB for scenes and import them to local database",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
query := args[0]
// Get API key from environment
apiKey := os.Getenv("TPDB_API_KEY")
if apiKey == "" {
return fmt.Errorf("TPDB_API_KEY environment variable is not set")
}
// Create TPDB scraper
scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey)
// Search TPDB
fmt.Printf("Searching TPDB for scenes matching '%s'...\n", query)
scenes, err := scraper.SearchScenes(context.Background(), query)
if err != nil {
return fmt.Errorf("failed to search TPDB: %w", err)
}
if len(scenes) == 0 {
fmt.Println("No scenes found on TPDB")
return nil
}
fmt.Printf("Found %d scene(s) on TPDB\n\n", len(scenes))
// Open database
database, err := getDB()
if err != nil {
return err
}
defer database.Close()
sceneStore := db.NewSceneStore(database)
performerStore := db.NewPerformerStore(database)
studioStore := db.NewStudioStore(database)
tagStore := db.NewTagStore(database)
// Import each scene
imported := 0
for _, sc := range scenes {
fmt.Printf("Importing: %s (TPDB ID: %s)\n", sc.Title, sc.SourceID)
// Import studio if present
if sc.Studio != nil {
if err := studioStore.Create(sc.Studio); err != nil {
// Studio might already exist, try to fetch it
studios, _ := studioStore.Search(sc.Studio.Name)
if len(studios) > 0 {
sc.StudioID = &studios[0].ID
}
} else {
sc.StudioID = &sc.Studio.ID
}
}
// Create scene
if err := sceneStore.Create(&sc); err != nil {
fmt.Printf(" ⚠ Warning: Failed to import scene: %v\n", err)
continue
}
// Import and link performers
for _, p := range sc.Performers {
if err := performerStore.Create(&p); err != nil {
// Performer might already exist
performers, _ := performerStore.Search(p.Name)
if len(performers) > 0 {
p.ID = performers[0].ID
}
}
if p.ID > 0 {
sceneStore.AddPerformer(sc.ID, p.ID)
}
}
// Import and link tags
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)
}
}
fmt.Printf(" ✓ Imported with local ID: %d\n", sc.ID)
imported++
}
fmt.Printf("\n✓ Successfully imported %d/%d scenes\n", imported, len(scenes))
return nil
},
}