Goondex/docs/FRONTEND_API_GUIDE.md
Stu Leak 16fb407a3c v0.1.0-dev4: Add web frontend with UI component library
- Implement full web interface with Go html/template server
- Add GX component library (buttons, dialogs, tables, forms, etc.)
- Create scene/performer/studio/movie detail and listing pages
- Add Adult Empire scraper for additional metadata sources
- Implement movie support with database schema
- Add import and sync services for data management
- Include comprehensive API and frontend documentation
- Add custom color scheme and responsive layout

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 10:47:30 -05:00

23 KiB

Goondex Frontend API Guide

For Frontend Developers

This guide explains how to interact with the Goondex API using JavaScript. No backend knowledge required - just JavaScript, HTML, CSS, and Bootstrap skills!

Table of Contents

  1. Getting Started
  2. Base URL
  3. Data Models
  4. API Endpoints
  5. Common Workflows
  6. Error Handling
  7. Real-time Progress Updates
  8. Complete Examples

Getting Started

All API endpoints return JSON data. You can use the fetch API to make requests from your JavaScript code.

Basic API Response Format

All API responses follow this structure:

{
  "success": true,           // boolean - whether the operation succeeded
  "message": "Success text", // string - human-readable message
  "data": { ... }           // object - actual data (optional)
}

Base URL

The API server runs at: http://localhost:8080 (by default)

All endpoints are prefixed with this URL.


Data Models

Performer

{
  "id": 123,
  "name": "Performer Name",
  "aliases": "Alias1, Alias2",

  // Physical Attributes
  "gender": "female",              // male/female/trans/other
  "birthday": "1995-03-15",        // YYYY-MM-DD
  "astrology": "Pisces",
  "birthplace": "Los Angeles, CA",
  "ethnicity": "Caucasian",
  "nationality": "US",             // ISO country code (US, GB, FR, etc.)
  "country": "United States",
  "eye_color": "Blue",
  "hair_color": "Blonde",
  "height": 165,                   // centimeters
  "weight": 55,                    // kilograms
  "measurements": "34C-24-36",
  "cup_size": "34C",
  "tattoo_description": "Dragon on left shoulder",
  "piercing_description": "Nose piercing",
  "boob_job": "False",             // "True" or "False" as string

  // Career
  "career": "2015-2023",
  "career_start_year": 2015,
  "career_end_year": 2023,
  "date_of_death": "",             // YYYY-MM-DD if applicable
  "active": true,

  // Media
  "image_path": "/path/to/image.jpg",
  "image_url": "https://example.com/image.jpg",
  "poster_url": "https://example.com/poster.jpg",
  "bio": "Biography text...",

  // Metadata
  "source": "tpdb",
  "source_id": "abc-123-def",
  "source_numeric_id": 456,
  "created_at": "2024-01-01T12:00:00Z",
  "updated_at": "2024-01-02T12:00:00Z"
}

Studio

{
  "id": 456,
  "name": "Studio Name",
  "parent_id": 123,                 // null if no parent studio
  "image_path": "/path/to/logo.jpg",
  "image_url": "https://example.com/logo.jpg",
  "description": "Studio description...",
  "source": "tpdb",
  "source_id": "xyz-789",
  "created_at": "2024-01-01T12:00:00Z",
  "updated_at": "2024-01-02T12:00:00Z"
}

Scene

{
  "id": 789,
  "title": "Scene Title",
  "code": "SCENE-001",              // DVD code or scene identifier
  "date": "2024-01-15",             // Release date YYYY-MM-DD
  "studio_id": 456,
  "description": "Scene description...",
  "image_path": "/path/to/thumbnail.jpg",
  "image_url": "https://example.com/thumbnail.jpg",
  "director": "Director Name",
  "url": "https://example.com/scene",
  "source": "tpdb",
  "source_id": "scene-123",
  "created_at": "2024-01-01T12:00:00Z",
  "updated_at": "2024-01-02T12:00:00Z",

  // Relationships (when populated)
  "performers": [ /* array of Performer objects */ ],
  "tags": [ /* array of Tag objects */ ],
  "studio": { /* Studio object */ }
}

Movie

{
  "id": 321,
  "title": "Movie Title",
  "date": "2024-01-01",             // Release date
  "studio_id": 456,
  "description": "Movie description...",
  "director": "Director Name",
  "duration": 120,                  // Duration in minutes
  "image_path": "/path/to/cover.jpg",
  "image_url": "https://example.com/cover.jpg",
  "back_image_url": "https://example.com/back-cover.jpg",
  "url": "https://example.com/movie",
  "source": "tpdb",
  "source_id": "movie-456",
  "created_at": "2024-01-01T12:00:00Z",
  "updated_at": "2024-01-02T12:00:00Z",

  // Relationships (when populated)
  "scenes": [ /* array of Scene objects */ ],
  "performers": [ /* array of Performer objects */ ],
  "tags": [ /* array of Tag objects */ ],
  "studio": { /* Studio object */ }
}

Tag

{
  "id": 111,
  "name": "Tag Name",
  "category_id": 5,
  "aliases": "Alias1, Alias2",
  "description": "Tag description...",
  "source": "tpdb",
  "source_id": "tag-789",
  "created_at": "2024-01-01T12:00:00Z",
  "updated_at": "2024-01-02T12:00:00Z"
}

API Endpoints

Search & Import

Endpoint: POST /api/import/performer

Description: Search for a performer and import all matching results from TPDB.

Request Body:

{
  "query": "performer name"
}

Example:

const response = await fetch('http://localhost:8080/api/import/performer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    query: 'Jane Doe'
  })
});

const result = await response.json();
// result = {
//   "success": true,
//   "message": "Imported 5 performer(s)",
//   "data": { "imported": 5, "found": 5 }
// }

Endpoint: POST /api/import/studio

Request Body:

{
  "query": "studio name"
}

Example:

const response = await fetch('http://localhost:8080/api/import/studio', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ query: 'Brazzers' })
});

const result = await response.json();

Endpoint: POST /api/import/scene

Description: Search for a scene and import all matching results. This also imports associated performers, studio, and tags.

Request Body:

{
  "query": "scene title"
}

Example:

const response = await fetch('http://localhost:8080/api/import/scene', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ query: 'Scene Title' })
});

const result = await response.json();

Bulk Import

4. Bulk Import All

Endpoint: POST /api/import/all

Description: Import all performers, studios, and scenes from your local database. This fetches full metadata from TPDB.

No Request Body Required

Example:

const response = await fetch('http://localhost:8080/api/import/all', {
  method: 'POST'
});

const result = await response.json();
// result.data contains import statistics

5. Bulk Import All Performers

Endpoint: POST /api/import/all-performers

Example:

const response = await fetch('http://localhost:8080/api/import/all-performers', {
  method: 'POST'
});

const result = await response.json();
// result = {
//   "success": true,
//   "message": "Imported 150/200 performers",
//   "data": {
//     "total": 200,
//     "imported": 150,
//     "skipped": 50,
//     "errors": 0
//   }
// }

6. Bulk Import All Studios

Endpoint: POST /api/import/all-studios

Example:

const response = await fetch('http://localhost:8080/api/import/all-studios', {
  method: 'POST'
});

const result = await response.json();

7. Bulk Import All Scenes

Endpoint: POST /api/import/all-scenes

Example:

const response = await fetch('http://localhost:8080/api/import/all-scenes', {
  method: 'POST'
});

const result = await response.json();

Sync

8. Sync All Data

Endpoint: POST /api/sync

Description: Synchronize all data with TPDB to get the latest updates.

Request Body (Optional):

{
  "force": false  // Set to true to force sync even if recently synced
}

Example:

const response = await fetch('http://localhost:8080/api/sync', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ force: true })
});

const result = await response.json();

9. Get Sync Status

Endpoint: GET /api/sync/status

Description: Get the last sync time for all entities.

Example:

const response = await fetch('http://localhost:8080/api/sync/status');
const result = await response.json();
// result.data contains sync status for each entity type

10. Search Everything

Endpoint: GET /api/search?q=query

Description: Search across performers, studios, scenes, and tags simultaneously.

Example:

const query = 'search term';
const response = await fetch(`http://localhost:8080/api/search?q=${encodeURIComponent(query)}`);
const result = await response.json();

// result.data = {
//   "performers": [...],
//   "studios": [...],
//   "scenes": [...],
//   "tags": [...],
//   "total": 25
// }

Real-time Progress Updates

For bulk imports, there are special endpoints that provide real-time progress updates using Server-Sent Events (SSE).

Bulk Import with Progress

These endpoints stream progress updates as the import happens:

  • GET /api/import/all-performers/progress
  • GET /api/import/all-studios/progress
  • GET /api/import/all-scenes/progress

Example with EventSource:

// Create an EventSource to listen for progress updates
const eventSource = new EventSource('http://localhost:8080/api/import/all-performers/progress');

eventSource.onmessage = function(event) {
  const update = JSON.parse(event.data);

  if (update.error) {
    console.error('Import error:', update.error);
    eventSource.close();
    return;
  }

  if (update.complete) {
    console.log('Import complete!', update.result);
    eventSource.close();
    return;
  }

  // Progress update
  console.log(`Progress: ${update.current}/${update.total}`);
  console.log(`Current item: ${update.name}`);
  console.log(`Status: ${update.status}`);

  // Update UI
  updateProgressBar(update.current, update.total);
};

eventSource.onerror = function(error) {
  console.error('EventSource error:', error);
  eventSource.close();
};

Progress Update Format:

{
  "current": 15,        // Current item number
  "total": 100,         // Total items to process
  "name": "Jane Doe",   // Name of current item being processed
  "status": "importing" // Status message
}

Completion Format:

{
  "complete": true,
  "result": {
    "total": 100,
    "imported": 95,
    "skipped": 4,
    "errors": 1
  }
}

Common Workflows

1. Search and Display Performers

// Search for performers
async function searchPerformers(searchQuery) {
  const response = await fetch(
    `http://localhost:8080/api/search?q=${encodeURIComponent(searchQuery)}`
  );
  const result = await response.json();

  if (result.success) {
    return result.data.performers;
  }
  throw new Error(result.message);
}

// Display in HTML
async function displayPerformers() {
  const performers = await searchPerformers('jane');

  const container = document.getElementById('performers-list');
  container.innerHTML = performers.map(p => `
    <div class="card">
      <img src="${p.image_url}" class="card-img-top" alt="${p.name}">
      <div class="card-body">
        <h5 class="card-title">${p.name}</h5>
        <p class="card-text">
          ${p.nationality ? getFlagEmoji(p.nationality) : ''}
          ${p.gender || 'Unknown'}
        </p>
        <a href="/performers/${p.id}" class="btn btn-primary">View Details</a>
      </div>
    </div>
  `).join('');
}

// Helper: Convert country code to flag emoji
function getFlagEmoji(countryCode) {
  const codePoints = countryCode
    .toUpperCase()
    .split('')
    .map(char => 127397 + char.charCodeAt());
  return String.fromCodePoint(...codePoints);
}

2. Import Data with Progress Bar

async function importPerformersWithProgress() {
  const progressBar = document.getElementById('progress-bar');
  const statusText = document.getElementById('status-text');

  const eventSource = new EventSource(
    'http://localhost:8080/api/import/all-performers/progress'
  );

  eventSource.onmessage = function(event) {
    const update = JSON.parse(event.data);

    if (update.error) {
      statusText.textContent = `Error: ${update.error}`;
      progressBar.style.width = '100%';
      progressBar.classList.add('bg-danger');
      eventSource.close();
      return;
    }

    if (update.complete) {
      statusText.textContent =
        `Complete! Imported ${update.result.imported}/${update.result.total}`;
      progressBar.style.width = '100%';
      progressBar.classList.add('bg-success');
      eventSource.close();
      return;
    }

    // Update progress
    const percent = (update.current / update.total) * 100;
    progressBar.style.width = `${percent}%`;
    statusText.textContent =
      `Importing ${update.name} (${update.current}/${update.total})`;
  };

  eventSource.onerror = function(error) {
    statusText.textContent = 'Connection error';
    progressBar.classList.add('bg-danger');
    eventSource.close();
  };
}

3. Sync Data

async function syncAllData(forceSync = false) {
  const response = await fetch('http://localhost:8080/api/sync', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ force: forceSync })
  });

  const result = await response.json();

  if (result.success) {
    console.log('Sync completed:', result.data);
    return result.data;
  }
  throw new Error(result.message);
}

// Check last sync status
async function checkSyncStatus() {
  const response = await fetch('http://localhost:8080/api/sync/status');
  const result = await response.json();

  if (result.success) {
    console.log('Last sync times:', result.data);
    return result.data;
  }
  throw new Error(result.message);
}

4. Search and Import New Performer

async function importNewPerformer(performerName) {
  const response = await fetch('http://localhost:8080/api/import/performer', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query: performerName })
  });

  const result = await response.json();

  if (result.success) {
    alert(`Successfully imported ${result.data.imported} performer(s)`);
    return result.data;
  }
  alert(`Failed: ${result.message}`);
  throw new Error(result.message);
}

// Usage with a form
document.getElementById('import-form').addEventListener('submit', async (e) => {
  e.preventDefault();
  const performerName = document.getElementById('performer-name').value;
  await importNewPerformer(performerName);
  // Refresh the performer list
  location.reload();
});

Error Handling

Best Practices

Always check the success field in the response:

async function safeApiCall(url, options = {}) {
  try {
    const response = await fetch(url, options);

    // Check HTTP status
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    const result = await response.json();

    // Check API success field
    if (!result.success) {
      throw new Error(result.message);
    }

    return result.data;
  } catch (error) {
    console.error('API Error:', error);
    throw error;
  }
}

// Usage
try {
  const performers = await safeApiCall('http://localhost:8080/api/search?q=jane');
  console.log('Performers:', performers);
} catch (error) {
  alert(`Error: ${error.message}`);
}

Common Error Responses

API Key Not Configured:

{
  "success": false,
  "message": "TPDB_API_KEY not configured"
}

No Results Found:

{
  "success": false,
  "message": "No performers found"
}

Search Failed:

{
  "success": false,
  "message": "Search failed: connection timeout"
}

Complete Examples

Example 1: Search Form with Results

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Goondex Search</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <div class="container mt-5">
    <h1>Search Goondex</h1>

    <form id="search-form" class="mb-4">
      <div class="input-group">
        <input type="text" id="search-input" class="form-control" placeholder="Search...">
        <button type="submit" class="btn btn-primary">Search</button>
      </div>
    </form>

    <div id="results"></div>
  </div>

  <script>
    const API_BASE = 'http://localhost:8080';

    document.getElementById('search-form').addEventListener('submit', async (e) => {
      e.preventDefault();

      const query = document.getElementById('search-input').value;
      const resultsDiv = document.getElementById('results');

      try {
        const response = await fetch(`${API_BASE}/api/search?q=${encodeURIComponent(query)}`);
        const result = await response.json();

        if (!result.success) {
          resultsDiv.innerHTML = `<div class="alert alert-danger">${result.message}</div>`;
          return;
        }

        const data = result.data;

        resultsDiv.innerHTML = `
          <h2>Results (${data.total} total)</h2>

          <h3>Performers (${data.performers.length})</h3>
          <div class="row">
            ${data.performers.map(p => `
              <div class="col-md-3 mb-3">
                <div class="card">
                  <img src="${p.image_url || '/static/placeholder.jpg'}" class="card-img-top">
                  <div class="card-body">
                    <h5 class="card-title">${p.name}</h5>
                    <p class="card-text">${p.nationality || ''}</p>
                  </div>
                </div>
              </div>
            `).join('')}
          </div>

          <h3>Studios (${data.studios.length})</h3>
          <ul class="list-group">
            ${data.studios.map(s => `
              <li class="list-group-item">${s.name}</li>
            `).join('')}
          </ul>
        `;
      } catch (error) {
        resultsDiv.innerHTML = `<div class="alert alert-danger">Error: ${error.message}</div>`;
      }
    });
  </script>
</body>
</html>

Example 2: Import Progress with Bootstrap

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Import Performers</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <div class="container mt-5">
    <h1>Import All Performers</h1>

    <button id="start-import" class="btn btn-primary mb-4">Start Import</button>

    <div class="progress mb-2" style="display: none;">
      <div id="progress-bar" class="progress-bar" role="progressbar" style="width: 0%">0%</div>
    </div>

    <div id="status" class="alert alert-info" style="display: none;"></div>
  </div>

  <script>
    const API_BASE = 'http://localhost:8080';

    document.getElementById('start-import').addEventListener('click', () => {
      const progressDiv = document.querySelector('.progress');
      const progressBar = document.getElementById('progress-bar');
      const statusDiv = document.getElementById('status');
      const button = document.getElementById('start-import');

      button.disabled = true;
      progressDiv.style.display = 'block';
      statusDiv.style.display = 'block';

      const eventSource = new EventSource(`${API_BASE}/api/import/all-performers/progress`);

      eventSource.onmessage = function(event) {
        const update = JSON.parse(event.data);

        if (update.error) {
          statusDiv.className = 'alert alert-danger';
          statusDiv.textContent = `Error: ${update.error}`;
          progressBar.className = 'progress-bar bg-danger';
          progressBar.style.width = '100%';
          button.disabled = false;
          eventSource.close();
          return;
        }

        if (update.complete) {
          const result = update.result;
          statusDiv.className = 'alert alert-success';
          statusDiv.textContent =
            `Complete! Imported ${result.imported}/${result.total} performers ` +
            `(${result.skipped} skipped, ${result.errors} errors)`;
          progressBar.className = 'progress-bar bg-success';
          progressBar.style.width = '100%';
          progressBar.textContent = '100%';
          button.disabled = false;
          eventSource.close();
          return;
        }

        const percent = Math.round((update.current / update.total) * 100);
        progressBar.style.width = `${percent}%`;
        progressBar.textContent = `${percent}%`;
        statusDiv.className = 'alert alert-info';
        statusDiv.textContent =
          `Importing: ${update.name} (${update.current}/${update.total})`;
      };

      eventSource.onerror = function() {
        statusDiv.className = 'alert alert-danger';
        statusDiv.textContent = 'Connection error';
        progressBar.className = 'progress-bar bg-danger';
        button.disabled = false;
        eventSource.close();
      };
    });
  </script>
</body>
</html>

Tips for Frontend Developers

1. Always Use encodeURIComponent for Query Parameters

// Good
const query = 'Jane Doe & Associates';
fetch(`/api/search?q=${encodeURIComponent(query)}`);

// Bad - will break with special characters
fetch(`/api/search?q=${query}`);

2. Handle Image Loading Errors

<img
  src="${performer.image_url}"
  onerror="this.src='/static/placeholder.jpg'"
  alt="${performer.name}"
>

3. Use Bootstrap Classes for Quick Styling

<!-- Alert messages -->
<div class="alert alert-success">Success!</div>
<div class="alert alert-danger">Error!</div>
<div class="alert alert-warning">Warning!</div>

<!-- Loading spinner -->
<div class="spinner-border" role="status">
  <span class="visually-hidden">Loading...</span>
</div>

<!-- Progress bar -->
<div class="progress">
  <div class="progress-bar" style="width: 75%">75%</div>
</div>

4. Debounce Search Input

function debounce(func, wait) {
  let timeout;
  return function(...args) {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, args), wait);
  };
}

// Usage
const searchInput = document.getElementById('search');
const debouncedSearch = debounce(performSearch, 300);
searchInput.addEventListener('input', (e) => debouncedSearch(e.target.value));

5. Format Dates for Display

function formatDate(dateString) {
  if (!dateString) return 'Unknown';
  const date = new Date(dateString);
  return date.toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  });
}

// Usage
console.log(formatDate('2024-01-15')); // "January 15, 2024"

6. Calculate Age from Birthday

function calculateAge(birthday) {
  if (!birthday) return null;
  const birthDate = new Date(birthday);
  const today = new Date();
  let age = today.getFullYear() - birthDate.getFullYear();
  const monthDiff = today.getMonth() - birthDate.getMonth();
  if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
    age--;
  }
  return age;
}

// Usage
const age = calculateAge('1995-03-15');
console.log(`Age: ${age}`);

Need Help?

If you have questions about the API or need clarification:

  1. Check the data model structures above
  2. Look at the complete examples
  3. Test endpoints using browser DevTools Network tab
  4. Consult your backend developer if you need custom endpoints

Happy coding!