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).
- Rider connects their Strava account via OAuth2
- Rider selects an activity and a photographer
- The service fetches the GPS stream for that activity (in-memory only)
- Candidate photos are queried from the database by time window
- A Haversine spatial join finds photos within ~50 m and ~60 s of the track
- Matched photo links are returned — the rider clicks through to the photographer's storefront
- 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
git clone <repo-url>
cd photo-link
python3 -m venv .venv
source .venv/bin/activatepip install -e ".[dev]"createdb photomatchOr with explicit credentials:
psql -U postgres -c "CREATE DATABASE photomatch;"cp .env.example .envEdit .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_secretmake migratemake runVisit http://localhost:8000.
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 resultsExpected output: matched photos with distance (m) and time delta (s).
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- Go to strava.com/settings/api
- Set Authorization Callback Domain to
localhost - Copy the Client ID and Client Secret into
.env - Start the server (
make run) and click Connect with Strava
The callback URL is http://localhost:8000/rides/callback/.
Create a Photographer record via the Django admin (/admin/) with:
platform:smugmugplatform_credentials:{"oauth_token": "...", "oauth_token_secret": "..."}
Then run:
make ingest # all photographers
python manage.py ingest_photos --photographer-id 1 # specific photographerOnly image metadata (GPS coordinates + timestamps) is stored — never photo files.
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
| 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 |
- Create
photographers/adapters/<platform>.pyimplementingfetch_photos() -> Iterator[PhotoMetadata] - Register it in
photographers/management/commands/ingest_photos.py:ADAPTER_REGISTRY = { "smugmug": SmugMugAdapter, "zenfolio": ZenfolioAdapter, # new }
- Create
Photographerrecords withplatform = "zenfolio"in the admin
The matching engine is platform-agnostic — it only sees (lat, lng, timestamp) tuples.
- 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)