---
title: Syndicating Photos to Flickr with POSSE
description: How I built a script that keeps Flickr in sync with my site — uploading new photos, deleting removed ones, moving photos between albums, and writing Flickr IDs back into sidecar files.
pubDate: '2026-03-21T18:55:00+01:00'
category: en/development
tags:
  - photography
  - workflow
seriesParent: building-the-photo-stream
seriesOrder: 4
---

I've been on Flickr since 2012, and when I added photos to this site I didn't want to run two parallel upload workflows. POSSE — Publish on your Own Site, Syndicate Elsewhere — is the [IndieWeb](https://indieweb.org/POSSE) principle that fits: the site is the source of truth; Flickr gets a copy. So I built a script that reads the site's photo tree and makes Flickr match it.

## The setup

- **Source**: photo collections under `src/content/photos/collections/`, each JPG paired with a Vision-generated JSON sidecar.
- **Target**: my Flickr account, one photoset per leaf collection.
- **Auth**: Flickr OAuth 1.0a with `delete` permission.
- **Tracking**: a gitignored `scripts/flickr-tracking.json` mapping local photo IDs to Flickr IDs and photoset IDs.

## What it does

`scripts/upload-to-flickr.js` runs in five phases on every invocation:

1. **Delete** — any photo in the tracking file that no longer has a local sidecar gets removed from Flickr
2. **Verify** — all tracked photos are checked against the Flickr API; any deleted directly on Flickr are reset and queued for re-upload
3. **Move** — photos whose collection has changed are removed from the old album and added to the new one
4. **Upload** — any sidecar without a `flickrId` gets uploaded with title, description, tags, and GPS
5. **Retry** — photos that uploaded successfully but failed album assignment are added to their album

After upload, the `flickrId` is written back into the sidecar JSON:

```json
{
  "id": "2025-10-06-121017",
  "flickrId": "55158155787",
  "title": ["Golden Temple Bell in Sunlit Foliage", ...],
  ...
}
```

The sidecar is the single source of truth for both local metadata and the Flickr reference. No separate `platforms.json` needed.

## Tracking

A local `scripts/flickr-tracking.json` maps photo IDs to their Flickr state:

```json
{
  "2025-10-06-121017": {
    "flickrId": "55158155787",
    "photosetId": "72177720324172664",
    "collectionSlug": "travels/asia/chiang-mai",
    "uploadedAt": "2026-03-21T10:00:00.000Z"
  }
}
```

This file is gitignored — it's machine state, not content. The `collectionSlug` field is what enables move detection: if the photo's current path no longer matches the stored slug, the script updates the album assignment on Flickr automatically.

## Metadata from sidecars

**Problem:** Flickr tags are a flat string and I want them to reflect both the photo's own tags (from Vision) and the collection it lives in — without duplicating that logic across the codebase.

**Implementation:** Tags are built from three sources — the photo's own tags, the camera model, and the collection path segments:

```js
const tags = [
  ...sidecar.tags,
  sidecar.exif.camera.toLowerCase().replace(/\s+/g, "-"),
  ...collectionSlug.split("/"),
]
  .map((t) => (t.includes(" ") ? `"${t}"` : t))
  .join(" ");
```

A Singapore photo tagged `["cityscape", "rain"]` shot on an iPhone 13 Pro Max gets: `cityscape rain iphone-13-pro-max travels asia singapore`.

GPS coordinates are stored in the sidecar as a DMS string and parsed to decimal degrees before being passed to `flickr.photos.geo.setLocation`:

```js
// "18 deg 48' 16.92" N, 98 deg 55' 18.92" E" → { lat: "18.804700", lon: "98.921922" }
function parseDMS(location) {
  const pattern =
    /(\d+)\s*deg\s*(\d+)'\s*([\d.]+)"\s*([NS]),\s*(\d+)\s*deg\s*(\d+)'\s*([\d.]+)"\s*([EW])/i;
  const m = location.match(pattern);
  const lat = (Number(m[1]) + Number(m[2]) / 60 + Number(m[3]) / 3600)
    * (m[4].toUpperCase() === "S" ? -1 : 1);
  const lon = (Number(m[5]) + Number(m[6]) / 60 + Number(m[7]) / 3600)
    * (m[8].toUpperCase() === "W" ? -1 : 1);
  return { lat: lat.toFixed(6), lon: lon.toFixed(6) };
}
```

## Photosets from collections

Collection titles are read from each `index.md` frontmatter:

```js
function readCollectionTitle(slug) {
  const mdPath = path.join("src/content/photos/collections", slug, "index.md");
  const content = fs.readFileSync(mdPath, "utf8");
  const m = content.match(/^title:\s*(.+)$/m);
  return m ? m[1].trim() : slug.split("/").at(-1);
}
```

If a photoset with that title already exists on Flickr, the photo is added to it. If not, a new one is created. Flickr photosets are flat — no nesting — so the leaf collection name is used: `travels/asia/chiang-mai` becomes "Chiang Mai".

## Deletion sync

**Problem:** If I delete a photo from the site, Flickr shouldn't keep showing it. But Flickr has no hook that fires when I delete a local file.

**Implementation:** The tracking file holds a record of every uploaded photo. On each run, the script compares its keys against local sidecar paths. Any ID in tracking without a local file means the photo was deleted from the site:

```js
const localIds = new Set(sidecarPaths.map((p) => path.basename(p, ".json")));
const deletedIds = Object.keys(tracking).filter((id) => !localIds.has(id));

for (const id of deletedIds) {
  await flickr("flickr.photos.delete", { photo_id: tracking[id].flickrId });
  delete tracking[id];
  saveTracking(tracking);
}
```

**Solution:** Deleting a file locally removes it from Flickr on the next run — no explicit command, no manual cleanup.

## Move detection

When a photo is moved to a different collection, the tracking file's `collectionSlug` no longer matches the file's current path. The script removes the photo from the old photoset and adds it to the new one:

```js
const movedPhotos = sidecarPaths.filter((p) => {
  const entry = tracking[path.basename(p, ".json")];
  return entry?.collectionSlug && entry.collectionSlug !== collectionSlugFromPath(p);
});
```

## Syncing stats back

A second script, `scripts/update-flickr.js`, fetches view counts, faves, and comment counts for all public photos and writes them back into the sidecar JSONs:

```js
sidecar.flickr = { views, faves, comments };
fs.writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2), "utf8");
```

These stats are available at build time. The photo detail page shows them alongside the Flickr link when `flickrId` is present:

```astro
{sidecar.flickrId && (
  <a href={`https://www.flickr.com/photos/${FLICKR_USER_ID}/${sidecar.flickrId}/`}>
    View on Flickr
    {sidecar.flickr && (
      <span>
        {sidecar.flickr.views} views · {sidecar.flickr.faves} faves · {sidecar.flickr.comments} comments
      </span>
    )}
  </a>
)}
```

## OAuth

Flickr requires OAuth 1.0a with `delete` permissions for full sync. Existing OAuth dance tools had issues with browser detection, so I wrote a minimal `scripts/flickr-auth.js` that handles the whole flow in the terminal — request token, authorization URL printed to stdout, PIN entered manually, access token exchanged and printed ready to paste into `.env.local`.

## The workflow

```bash
pnpm flickr:dry-run   # preview what would be uploaded or deleted
pnpm flickr:upload    # sync: delete removed, verify, move, upload new
pnpm flickr:update    # pull stats back into sidecars
```

The site stays the source. Flickr stays in sync.

## What to take away

- POSSE in practice is one script and one tracking file. The site is the source; the external platform is a reflection — never the other way around.
- Write the external ID (`flickrId`) back into the same sidecar that holds the local metadata. Avoid parallel state files — one source of truth per photo.
- Keep the tracking file (local IDs → remote IDs + slugs) gitignored; it's machine state, not content.
- A single `collectionSlug` field in the tracking record is enough to implement move detection between nested albums — no diffing, no history.
- Flickr photosets are flat. Map your nested collection tree to the leaf name and push the hierarchy into tags.
