← Blog

Syndicating Photos to Flickr with POSSE

POSSE — Publish on your Own Site, Syndicate Elsewhere — is a principle from the IndieWeb movement. The idea is simple: your site is the source of truth. External platforms like Flickr get a copy, not the original.

I’ve been on Flickr since 2012. Rather than managing two separate upload workflows, I built a script that treats my site as the canonical source and syncs everything outward automatically.

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

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

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);
}

Deleting a file locally removes it from Flickr on the next run.

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. Since existing OAuth dance tools had issues with browser detection, I wrote a minimal scripts/flickr-auth.js that handles the flow entirely 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.

← Blog