Skip to content

markmcmurray/photo-link

Repository files navigation

Photo Link

Photo-matching service for endurance sports photographers. Connects riders' GPS tracks (via Strava) with geotagged event photos so athletes can find photos of themselves without scrolling through thousands of images.

Discovery layer only — we match metadata, never host photos or handle payments. Riders are linked directly to the photographer's existing storefront (SmugMug, Zenfolio, etc).


How it works

  1. Rider connects their Strava account via OAuth2
  2. Rider selects an activity and a photographer
  3. The service fetches the GPS stream for that activity (in-memory only)
  4. Candidate photos are queried from the database by time window
  5. A Haversine spatial join finds photos within ~50 m and ~60 s of the track
  6. Matched photo links are returned — the rider clicks through to the photographer's storefront

Requirements

  • Python 3.12+
  • PostgreSQL 14+
  • A Strava API application (for OAuth, free at strava.com/settings/api)
  • (Optional) A SmugMug API application for live photo ingestion

Setup

1. Clone and create a virtual environment

git clone <repo-url>
cd photo-link
python3 -m venv .venv
source .venv/bin/activate

2. Install dependencies

pip install -e ".[dev]"

3. Create the database

createdb photomatch

Or with explicit credentials:

psql -U postgres -c "CREATE DATABASE photomatch;"

4. Configure environment variables

cp .env.example .env

Edit .env:

SECRET_KEY=your-secret-key-here          # generate with: python -c "import secrets; print(secrets.token_urlsafe(50))"
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1

# Database
DB_NAME=photomatch
DB_USER=postgres
DB_PASSWORD=
DB_HOST=localhost
DB_PORT=5432

# Strava — from https://www.strava.com/settings/api
STRAVA_CLIENT_ID=your_client_id
STRAVA_CLIENT_SECRET=your_client_secret

# SmugMug — from https://api.smugmug.com/api/developer/apply (optional)
SMUGMUG_API_KEY=your_api_key
SMUGMUG_API_SECRET=your_api_secret

5. Apply migrations

make migrate

6. Run the development server

make run

Visit http://localhost:8000.


Demo (no Strava credentials needed)

Verify the matching engine end-to-end using synthetic data:

make seed    # creates "Demo Photographer" + ~200 photos along Sa Calobra
make demo    # loads fixture GPS track, runs matching, prints results

Expected output: matched photos with distance (m) and time delta (s).


Running tests

make test          # all tests
pytest -v          # verbose
pytest matching/tests/test_engine.py              # single file
pytest matching/tests/test_engine.py::TestFindMatches::test_exact_match  # single test

Strava OAuth setup

  1. Go to strava.com/settings/api
  2. Set Authorization Callback Domain to localhost
  3. Copy the Client ID and Client Secret into .env
  4. Start the server (make run) and click Connect with Strava

The callback URL is http://localhost:8000/rides/callback/.


Ingesting photos from SmugMug

Create a Photographer record via the Django admin (/admin/) with:

  • platform: smugmug
  • platform_credentials: {"oauth_token": "...", "oauth_token_secret": "..."}

Then run:

make ingest                              # all photographers
python manage.py ingest_photos --photographer-id 1   # specific photographer

Only image metadata (GPS coordinates + timestamps) is stored — never photo files.


Project structure

photo-link/
├── matching/                  # Matching engine (no Django deps in engine.py)
│   ├── engine.py              # haversine(), find_matches(), core types
│   ├── views.py               # POST /api/match/ + GET /api/photographers/
│   ├── fixtures/
│   │   └── sa_calobra_track.json   # 600-point demo GPS track
│   ├── management/commands/
│   │   └── demo_match.py     # end-to-end demo command
│   └── tests/
│       ├── test_haversine.py
│       └── test_engine.py
├── photographers/             # Photo metadata index + adapters
│   ├── models.py              # Photographer, Photo
│   ├── adapters/
│   │   ├── base.py            # PhotoMetadata dataclass + PhotoSourceAdapter Protocol
│   │   └── smugmug.py         # SmugMug OAuth1 adapter
│   └── management/commands/
│       ├── seed_demo_data.py
│       └── ingest_photos.py
├── rides/                     # Strava OAuth + API client
│   ├── models.py              # StravaToken
│   ├── strava_client.py       # Strava API v3 wrapper
│   ├── views.py               # OAuth flow + activity/stream endpoints
│   └── urls.py
└── photomatch/                # Django project root
    ├── settings.py
    └── urls.py

Make targets

Target Description
make migrate Apply database migrations
make run Start development server on port 8000
make test Run all tests with pytest
make seed Seed demo photographer and ~200 photos
make demo Run end-to-end demo match (no external APIs)
make ingest Ingest photos from all configured photographer platforms

Adding a new photo source adapter

  1. Create photographers/adapters/<platform>.py implementing fetch_photos() -> Iterator[PhotoMetadata]
  2. Register it in photographers/management/commands/ingest_photos.py:
    ADAPTER_REGISTRY = {
        "smugmug": SmugMugAdapter,
        "zenfolio": ZenfolioAdapter,  # new
    }
  3. Create Photographer records with platform = "zenfolio" in the admin

The matching engine is platform-agnostic — it only sees (lat, lng, timestamp) tuples.


Strava API compliance notes

  • GPS streams are never persisted — fetched, matched, and discarded in-memory
  • Activity data is only ever returned to the authenticated athlete who owns it
  • Photographers never see rider GPS routes
  • Rate limits: 100 requests / 15 min, 1000 requests / day (default tier)

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors