Syndicating Photos to Flickr with POSSE

Category: Development

Tags: photography, workflow


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 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:

{
  "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:

{
  "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:

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:

// "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:

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:

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:

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:

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:

{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

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.