After recently recovering photos from an old backup of my personal Flickr account lewismoten, I turned my attention to another account I hadn’t logged into for many years: Dedric Mauriac.
That account is closely tied to my time in Second Life, where I spent years documenting life inside the virtual world through screenshots and blog posts.
Alongside the Flickr account, I also maintained a dedicated WordPress blog:
https://dedricmauriac.wordpress.com
That blog served as the narrative layer for many of the images I captured in-world.
What I discovered recently was both encouraging and slightly alarming: the photos were still there — but I wasn’t sure for how long.
This post documents the history behind the account, the concern about losing the archive, and the process I used to recover the entire Flickr library.
Life as Dedric Mauriac
The Dedric Mauriac blog focused largely on the technology and experiences surrounding Second Life.
I spent a great deal of time experimenting with the platform’s scripting language and building interactive gadgets that people could use in-world. Some of these tools were adopted by businesses, teachers, and organizations operating inside the virtual world.
Second Life, at the time, was more than a game. It was a social platform, a development sandbox, and a place where communities formed around shared interests.
One of the areas where I became heavily involved was with Relay for Life events inside Second Life. Each year, the community organized large fundraising events for cancer research, and I formed a team that helped raise funds while providing entertainment and activities for attendees during the summer events.
Those events brought together builders, musicians, designers, educators, and volunteers from around the world.
Looking back now, I realize that many of the skills I developed there — organizing events, coordinating volunteers, and building communities — eventually translated into the work I do today in Front Royal and Warren County.
At the time I didn’t recognize it as leadership training. I simply saw it as participating in an online community that felt alive and collaborative in ways I hadn’t experienced before.
Second Life helped me find a community in an online world unlike anything I had experienced previously.
Documenting My “Second Life”
The Dedric Mauriac WordPress blog wasn’t narrowly focused on one topic.
Instead it reflected whatever I was exploring at the time:
- scripting experiments
- interactive gadgets
- product announcements
- events I attended
- projects I was working on
- snapshots of everyday life inside the virtual world
There was a freedom in that approach.
I didn’t try to lock the blog into one specific theme. I simply documented my “second life” as it unfolded. “Second Life” was the theme, and I didn’t want it to take over my personal blog at the time, which followed a similar approach in my life, though we referred to the community of blogs as “The Blogosphere” at the time.
Looking at my personal blog today, I realize I still approach writing in a very similar way — mixing technology, governance, creativity, and community work.
In many ways, the habits I developed during those years in Second Life shaped how I document my work today.
A Snapshot Distribution Network
One feature of the Second Life viewer made documenting this world especially easy: the snapshot email feature.
You could take a screenshot in-world and send it via email along with a message describing what was happening.
Those emails could be delivered to multiple services simultaneously.
My typical destinations included:
- Flickr
- SnapZilla (SLpics / SL Universe)
- blogHUD
- my WordPress blog at dedricmauriac.wordpress.com
Each platform served a different role:
| Platform | Purpose |
|---|---|
| WordPress Blog | narrative posts |
| Flickr | long-term photo storage |
| SnapZilla | Second Life community interaction |
| blogHUD | images embedded in blog posts |
This allowed me to capture moments in-world and distribute them across several communities almost instantly. The problem with blogHUD is that their images were always fairly small compared to the original images posted to Flickr and SnapZilla.
Mapping the Social Web
If you visit my old Second Life wiki profile, you’ll find something that now feels like a time capsule of the late 2000s internet.
https://wiki.secondlife.com/wiki/User:Dedric_Mauriac
On that page I created a social networking flowchart showing how snapshots and posts moved from Second Life into the wider web.
At the far right of that process was the snapshot email system, which fed content into a surprisingly large ecosystem of platforms.
Some of the primary destinations included:
blogHUD, Flickr, SL Universe, Mixoom, WordPress, Cinchcast, Odiogo, and Subscribe-O-Matic.
From there the content propagated further through cross-posting tools like Ping.fm, distributing updates to networks including:
Twitter, Facebook, MySpace, Tumblr, LinkedIn, Identi.ca, FriendFeed, Plurk, and Jaiku.
From there the network expanded again into services that were popular at the time but have largely disappeared today:
LiveJournal, Xanga, Friendster, Bebo, Hi5, Multiply, Vox, TypePad, Posterous, Brightkite, Yahoo Meme, and many others.
Looking back now, that diagram reads almost like a map of the entire Web 2.0 ecosystem of the late 2000s.
At the time, this interconnected system felt completely normal.
Today it feels almost surreal.
Snapshot Tweaks and Viewer Hacks
The snapshot email feature had one frustrating limitation.
By default, the viewer restricted messages to about 700 characters.
That was barely enough space for a caption.
Eventually I discovered the limit was defined inside the viewer’s XML interface files, which meant it could be modified locally.
The file responsible was:
C:\Program Files (x86)\SecondLife\skins\default\xui\en-us\floater_postcard.xml
Inside that file was the message editor definition:
<text_editor max_length="700" name="msg_form">
By modifying the max_length value I increased the limit to 6,526 characters, allowing much longer blog entries to be written directly inside the viewer.
The catch was that every viewer update overwrote the file, so I had to repeat the modification each time.
Updates were frequent. Sometimes I remembered. Sometimes I didn’t. I had to restart to apply the changes, and I would often discover the need after I had taken a snapshot and started writing the postcard. I would lose the image if I didn’t email it before shutting down.
As a result, many posts on my Second Life blog fall into two clear categories:
- about 700 characters
- about 6,500 characters
Those numbers reflect whether the viewer modification was active at the time.
Documenting the Technique
Because other users encountered the same limitation, I eventually wrote a guide explaining these tweaks.
That page still exists on the Second Life Wiki:
https://wiki.secondlife.com/wiki/Advanced_Snapshot_Tips
Published on January 17, 2010, it explains how to:
- send snapshots to multiple email addresses
- pre-populate the recipient field
- extend the message length limit
Looking back now, it’s interesting how much of my blogging workflow depended on small adjustments like this.
The Flickr Account Problem
The Dedric Mauriac Flickr account predates Flickr’s transition to Yahoo authentication.
When Flickr began migrating accounts to Yahoo many years ago, I ran into problems linking the account. I tried several times but could never complete the process successfully.
Eventually, I gave up trying to access the account.
Over the years, I attempted to log in again from time to time, but each attempt failed at some point in the migration process.
Recently, I decided to give it one more try. Initially, I failed, but then I tried a different approach, which unexpectedly worked.
After many years locked out of the account, I was finally able to log in again.
A Possible Race Against Deletion
Once inside the account, I immediately noticed something surprising.
The free account was well over Flickr’s 1,000 photo limit, yet none of the images had been deleted.
My suspicion is that because the account never completed some of Flickr’s later transitions or agreements, the automated cleanup process may never have been triggered.
That meant there might be a limited window to retrieve everything before anything changed.
Attempting Flickr’s Export
My first step was to request a full Flickr data export.
I submitted the request several days ago.
Unfortunately the export appears to be stuck in a very large queue.
Rather than wait indefinitely, I started looking for another way to retrieve the data.
Rediscovering an Old API Key
While exploring the account settings, I noticed something I had completely forgotten about.
The account already had a Flickr API key associated with it.
The key existed because of an old project called SLideViewr, which allowed Flickr images to appear on objects inside Second Life using streaming media.
Years ago that key helped push Flickr images into Second Life.
Now it could help pull those images back out again.
Writing a Recovery Script
Rather than continue waiting for the export, I prompted ChatGPT to help generate a script that could retrieve everything through the Flickr API.
The script needed to:
- download all original images
- capture metadata including titles, descriptions, comments, albums, groups, and EXIF data
- resolve Flickr user IDs into usernames
- cache repeated lookups
- resume if interrupted
help me create a node javascript that will go through all 1,592 flickr photos and download originals, including metadata such as date, title, description, comments, groups, albums, lookup peoples names, etc. It needs to cache information so the same data is not requested a second time, and needs to ability to pickup where it left off. I have an API key and secret. I’d like to save everything in a folder “dedricmauriac”. The id is 90816264@N00
The resulting program was written in Node.js and ended up being roughly 750 lines long.
Before downloading anything, the script performed an OAuth authentication flow to generate an access token with read permissions.
Once authenticated, the recovery process began.
Recovery Results
The script began running at: 2026-03-06T02:42:28.918Z
and completed at: 2026-03-06T03:27:38.352Z
Total runtime: 45 minutes and 9 seconds
During that time Flickr reported:
- 7,994 API calls
- 1 authenticated user
In total, the script retrieved approximately 1,592 photos along with their associated metadata. (116 MB)
- Per Photo (~5 api calls = 7,960 calls)
- flickr.photos.getInfo
- flickr.photos.getSizes
- flickr.photos.comments.getList
- flickr.photos.getExif
- flickr.photos.getAllContext
- Additional calls (~34 calls)
- flickr.people.getInfo
- flickr.photosets.getInfo
- flickr.photos.search
On average, each photo required about 2 seconds to download, including its metadata.
API Usage
The maximum number of queries per second during the recovery was recorded at 1.34 QPS. Flickr’s documentation notes a guideline of 3,600 API calls per hour per key.
The script completed its run in about 45 minutes, which meant the requests were distributed across two separate clock hours. Based on the dashboard statistics, it appears that the total number of requests may have briefly exceeded that hourly guideline.
Interestingly, the API did not enforce a hard cutoff when the threshold was crossed. Several hours later the API key remains active and usable. This suggests that Flickr’s rate limiting likely considers more than just a simple hourly total and may account for pacing, burst patterns, or other signals when determining abusive behavior.
In any case, the recovery process completed successfully and all of the images and metadata have now been retrieved.

A Note on the Script (and AI)
It’s worth being honest about how the recovery script came together.
The Node.js script is about 750 lines of code, and while it’s largely boilerplate, writing it entirely from scratch would have taken quite a while.
Not because the ideas are complicated, but because the details add up:
- researching the Flickr API
- implementing OAuth authentication
- testing request signatures
- handling pagination
- caching lookups
- implementing resume logic
Normally I would have built the script piece by piece, testing each step along the way. That process likely would have taken several hours, possibly most of an afternoon.
Instead, with a carefully written prompt, ChatGPT generated a working foundation for the script almost instantly.
That doesn’t mean the work disappears entirely. I still had to review the code, configure the environment, authenticate the application, and monitor the run.
But a significant amount of the boilerplate groundwork appeared in seconds.
It’s hard not to find that a little astonishing.
Thinking About the Future of Programming
Experiences like this make me wonder what programming will look like for the next generation.
For decades, programmers spent a large amount of time writing glue code, wiring APIs together, and debugging authentication flows.
Now much of that scaffolding can be generated almost instantly.
Perhaps the role of programmers will shift away from writing every line manually and toward:
- designing systems
- guiding AI tools
- reviewing generated code
- verifying correctness and security
In other words, the skill may become less about typing code and more about thinking clearly about problems. However, even AI can be used to review existing code and identify security concerns. The landscape is changing – not just for the next generation, but the current one that saw the inception of coding become a mainstream and respectable high-paying career.
The Script
For anyone interested in performing a similar recovery, the full script is included below with a few changes.
Install
mkdir flickr-backupcd flickr-backupnpm init -ynpm install axios oauth-1.0a dotenv
Create a .env file
FLICKR_API_KEY=your_api_keyFLICKR_API_SECRET=your_api_secretFLICKR_USER_ID=90816264@N00BACKUP_DIR=dedricmauriacAGENT=SLideViewer
backup-flickr.js
#!/usr/bin/env node/** * Flickr backup script * * Features: * - OAuth 1.0a PIN flow * - Resumable backups * - File-based caching * - Downloads original images when available * - Saves rich metadata per photo * * Usage: * node backup-flickr.js auth * node backup-flickr.js backup */require("dotenv").config();const fs = require("fs");const fsp = require("fs/promises");const path = require("path");const crypto = require("crypto");const readline = require("readline");const axios = require("axios");const OAuth = require("oauth-1.0a");const API_KEY = process.env.FLICKR_API_KEY;const API_SECRET = process.env.FLICKR_API_SECRET;const USER_ID = process.env.FLICKR_USER_ID || "90816264@N00";const ROOT_DIR = process.env.BACKUP_DIR || "dedricmauriac";const AGENT = process.env.AGENT;if (!API_KEY || !API_SECRET) { console.error("Missing FLICKR_API_KEY or FLICKR_API_SECRET in .env"); process.exit(1);}const DIRS = { root: ROOT_DIR, cache: path.join(ROOT_DIR, "_cache"), photos: path.join(ROOT_DIR, "photos"), albums: path.join(ROOT_DIR, "albums"), people: path.join(ROOT_DIR, "people"), groups: path.join(ROOT_DIR, "groups"), logs: path.join(ROOT_DIR, "_logs"),};const FILES = { state: path.join(DIRS.cache, "state.json"), tokens: path.join(DIRS.cache, "tokens.json"), peopleCache: path.join(DIRS.cache, "people-cache.json"), setCache: path.join(DIRS.cache, "set-cache.json"), groupCache: path.join(DIRS.cache, "group-cache.json"), photoIndex: path.join(DIRS.cache, "photo-index.json"), photosetList: path.join(DIRS.cache, "photosets-list.json"),};const REST_URL = "https://www.flickr.com/services/rest";const REQUEST_TOKEN_URL = "https://www.flickr.com/services/oauth/request_token";const AUTHORIZE_URL = "https://www.flickr.com/services/oauth/authorize";const ACCESS_TOKEN_URL = "https://www.flickr.com/services/oauth/access_token";const http = axios.create({ timeout: 60000, maxRedirects: 5, headers: { "User-Agent": AGENT, },});const oauth = OAuth({ consumer: { key: API_KEY, secret: API_SECRET }, signature_method: "HMAC-SHA1", hash_function(baseString, key) { return crypto.createHmac("sha1", key).update(baseString).digest("base64"); },});function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms));}function sanitizeFileName(value) { return String(value || "") .replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_") .replace(/\s+/g, " ") .trim() .slice(0, 180);}function safeJsonParse(text, fallback = null) { try { return JSON.parse(text); } catch { return fallback; }}async function ensureDir(dir) { await fsp.mkdir(dir, { recursive: true });}async function ensureDirs() { for (const dir of Object.values(DIRS)) { await ensureDir(dir); }}async function exists(file) { try { await fsp.access(file, fs.constants.F_OK); return true; } catch { return false; }}async function readJson(file, fallback = {}) { if (!(await exists(file))) return fallback; const text = await fsp.readFile(file, "utf8"); return safeJsonParse(text, fallback);}async function writeJson(file, data) { const tmp = `${file}.tmp`; await fsp.writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); await fsp.rename(tmp, file);}async function appendLog(name, line) { const file = path.join(DIRS.logs, name); await fsp.appendFile(file, line + "\n", "utf8");}function ask(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }); });}function parseQueryString(text) { const params = new URLSearchParams(text); const out = {}; for (const [k, v] of params.entries()) out[k] = v; return out;}async function signedGet(url, extraParams = {}, token = null) { const requestData = { url, method: "GET", data: extraParams, }; const authData = oauth.authorize( requestData, token ? { key: token.oauth_token, secret: token.oauth_token_secret } : undefined ); const allParams = { ...extraParams, ...authData, }; const res = await http.get(url, { params: allParams }); return res.data;}async function flickrCall(method, params = {}, token = null) { const data = await signedGet( REST_URL, { method, format: "json", nojsoncallback: 1, ...params, }, token ); if (!data || data.stat !== "ok") { const msg = data?.message || "Unknown Flickr API error"; const code = data?.code || "unknown"; throw new Error(`${method} failed (${code}): ${msg}`); } return data;}async function getTokens() { return readJson(FILES.tokens, null);}async function saveTokens(tokens) { await writeJson(FILES.tokens, tokens);}async function authFlow() { await ensureDirs(); console.log("Starting Flickr OAuth PIN flow..."); const reqText = await signedGet( REQUEST_TOKEN_URL, { oauth_callback: "oob" }, null ); const reqToken = parseQueryString(reqText); if (!reqToken.oauth_token || !reqToken.oauth_token_secret) { throw new Error("Could not obtain request token."); } const url = `${AUTHORIZE_URL}?oauth_token=${encodeURIComponent( reqToken.oauth_token )}&perms=read`; console.log("\nOpen this URL in your browser:\n"); console.log(url); console.log(""); const verifier = await ask("Paste the Flickr verifier / PIN here: "); const accessText = await signedGet( ACCESS_TOKEN_URL, { oauth_token: reqToken.oauth_token, oauth_verifier: verifier, }, { oauth_token: reqToken.oauth_token, oauth_token_secret: reqToken.oauth_token_secret, } ); const access = parseQueryString(accessText); if (!access.oauth_token || !access.oauth_token_secret) { throw new Error("Could not obtain access token."); } const tokens = { oauth_token: access.oauth_token, oauth_token_secret: access.oauth_token_secret, user_nsid: access.user_nsid, username: access.username, fullname: access.fullname, created_at: new Date().toISOString(), }; await saveTokens(tokens); console.log("\nSaved access token."); console.log(`Authenticated as: ${tokens.username} (${tokens.user_nsid})`);}async function getState() { return readJson(FILES.state, { started_at: new Date().toISOString(), updated_at: new Date().toISOString(), page: 1, per_page: 500, total_pages: null, total_photos: null, processed: 0, completed_photo_ids: {}, failed_photo_ids: {}, });}async function saveState(state) { state.updated_at = new Date().toISOString(); await writeJson(FILES.state, state);}async function getCache(file) { return readJson(file, {});}async function setCachedValue(file, key, value) { const cache = await getCache(file); cache[key] = value; await writeJson(file, cache); return value;}async function getCachedValue(file, key) { const cache = await getCache(file); return cache[key];}async function resolvePerson(userId, token) { const cached = await getCachedValue(FILES.peopleCache, userId); if (cached) return cached; try { const data = await flickrCall("flickr.people.getInfo", { user_id: userId }, token); const p = data.person; const person = { nsid: p.nsid, username: p.username?._content || "", realname: p.realname?._content || "", path_alias: p.path_alias || "", location: p.location?._content || "", photosurl: p.photosurl?._content || "", profileurl: p.profileurl?._content || "", iconserver: p.iconserver || "", iconfarm: p.iconfarm || "", fetched_at: new Date().toISOString(), }; await setCachedValue(FILES.peopleCache, userId, person); const personFile = path.join(DIRS.people, `${sanitizeFileName(userId)}.json`); await writeJson(personFile, person); await sleep(250); return person; } catch (err) { const fallback = { nsid: userId, username: "", realname: "", unresolved: true, error: err.message, fetched_at: new Date().toISOString(), }; await setCachedValue(FILES.peopleCache, userId, fallback); return fallback; }}async function resolvePhotoset(setId, ownerId, token) { const cached = await getCachedValue(FILES.setCache, setId); if (cached) return cached; try { const data = await flickrCall( "flickr.photosets.getInfo", { photoset_id: setId, user_id: ownerId || USER_ID }, token ); const s = data.photoset; const setInfo = { id: s.id, owner: s.owner, title: s.title?._content || "", description: s.description?._content || "", primary: s.primary || "", photos: s.photos || "", videos: s.videos || "", count_views: s.count_views || "", date_create: s.date_create || "", date_update: s.date_update || "", fetched_at: new Date().toISOString(), }; await setCachedValue(FILES.setCache, setId, setInfo); const setFile = path.join(DIRS.albums, `${sanitizeFileName(setId)}.json`); await writeJson(setFile, setInfo); await sleep(250); return setInfo; } catch (err) { const fallback = { id: setId, owner: ownerId || USER_ID, unresolved: true, error: err.message, fetched_at: new Date().toISOString(), }; await setCachedValue(FILES.setCache, setId, fallback); return fallback; }}async function resolveGroup(group) { if (!group?.id && !group?.nsid) return group; const key = group.id || group.nsid; const cached = await getCachedValue(FILES.groupCache, key); if (cached) return cached; const record = { id: group.id || group.nsid, name: group.title || group.name || "", path_alias: group.path_alias || "", cached_at: new Date().toISOString(), }; await setCachedValue(FILES.groupCache, key, record); const file = path.join(DIRS.groups, `${sanitizeFileName(key)}.json`); await writeJson(file, record); return record;}async function buildPhotoListPage(page, perPage, token) { const extras = [ "description", "date_upload", "date_taken", "last_update", "owner_name", "icon_server", "original_format", "geo", "tags", "machine_tags", "o_dims", "views", "media", "path_alias", "url_o", ].join(","); // Using flickr.photos.search with authenticated access and user_id=me/user_id return flickrCall( "flickr.photos.search", { user_id: USER_ID, page, per_page: perPage, extras, sort: "date-posted-asc", }, token );}async function fetchPhotoInfo(photoId, secret, token) { return flickrCall("flickr.photos.getInfo", { photo_id: photoId, secret }, token);}async function fetchPhotoExif(photoId, secret, token) { try { return await flickrCall("flickr.photos.getExif", { photo_id: photoId, secret }, token); } catch (err) { return { stat: "error", error: err.message }; }}async function fetchPhotoComments(photoId, token) { try { return await flickrCall("flickr.photos.comments.getList", { photo_id: photoId }, token); } catch (err) { return { stat: "error", error: err.message }; }}async function fetchPhotoContexts(photoId, token) { try { return await flickrCall("flickr.photos.getAllContexts", { photo_id: photoId }, token); } catch (err) { return { stat: "error", error: err.message }; }}async function fetchPhotoSizes(photoId, token) { return flickrCall("flickr.photos.getSizes", { photo_id: photoId }, token);}function chooseOriginalSize(sizesResponse) { const sizes = sizesResponse?.sizes?.size || []; if (!Array.isArray(sizes)) return null; const exact = sizes.find((s) => String(s.label).toLowerCase() === "original"); if (exact) return exact; return sizes[sizes.length - 1] || null;}async function downloadFile(url, destFile) { if (await exists(destFile)) return false; const res = await http.get(url, { responseType: "stream" }); await ensureDir(path.dirname(destFile)); await new Promise((resolve, reject) => { const out = fs.createWriteStream(destFile); res.data.pipe(out); out.on("finish", resolve); out.on("error", reject); }); return true;}function toArray(value) { if (!value) return []; return Array.isArray(value) ? value : [value];}async function processPhoto(photoStub, token, state) { const photoId = String(photoStub.id); const photoDir = path.join(DIRS.photos, photoId); const metadataFile = path.join(photoDir, "metadata.json"); if (state.completed_photo_ids[photoId]) { return { skipped: true }; } await ensureDir(photoDir); const info = await fetchPhotoInfo(photoId, photoStub.secret, token); const exif = await fetchPhotoExif(photoId, photoStub.secret, token); const comments = await fetchPhotoComments(photoId, token); const contexts = await fetchPhotoContexts(photoId, token); const sizes = await fetchPhotoSizes(photoId, token); const ownerId = info?.photo?.owner?.nsid || photoStub.owner || USER_ID; const owner = await resolvePerson(ownerId, token); const commentPeople = []; const commentList = toArray(comments?.comments?.comment); for (const c of commentList) { const authorId = c.author; if (authorId) { const p = await resolvePerson(authorId, token); commentPeople.push(p); } } const sets = toArray(contexts?.set); const pools = toArray(contexts?.pool); const resolvedSets = []; for (const set of sets) { const setId = set.id || set.photoset_id; if (!setId) continue; const setInfo = await resolvePhotoset(setId, ownerId, token); resolvedSets.push({ id: setId, title: set.title || setInfo.title || "", url: set.url || "", info: setInfo, }); } const resolvedPools = []; for (const pool of pools) { const groupInfo = await resolveGroup(pool); resolvedPools.push({ ...pool, info: groupInfo, }); } const chosenSize = chooseOriginalSize(sizes); let downloadedOriginal = null; if (chosenSize?.source) { const extFromSource = path.extname(new URL(chosenSize.source).pathname) || ""; const extFromFormat = photoStub.originalformat ? `.${String(photoStub.originalformat).toLowerCase()}` : ""; const ext = extFromSource || extFromFormat || ".jpg"; const imageFile = path.join(photoDir, `original${ext}`); const didDownload = await downloadFile(chosenSize.source, imageFile); downloadedOriginal = { path: path.relative(DIRS.root, imageFile), label: chosenSize.label, width: chosenSize.width, height: chosenSize.height, source: chosenSize.source, downloaded_now: didDownload, }; } const title = info?.photo?.title?._content || photoStub.title || ""; const description = info?.photo?.description?._content || photoStub.description?._content || ""; const notes = toArray(info?.photo?.notes?.note); const tags = toArray(info?.photo?.tags?.tag).map((t) => ({ id: t.id || "", author: t.author || "", authorname: t.authorname || "", raw: t.raw || "", content: t._content || "", machine_tag: t.machine_tag || "0", })); const metadata = { backup: { photo_id: photoId, backed_up_at: new Date().toISOString(), }, photo_stub: photoStub, owner, photo: info?.photo || null, extracted: { title, description, notes, tags, comments: commentList, comment_people: commentPeople, albums: resolvedSets, groups: resolvedPools, exif: exif?.photo?.exif || [], sizes: toArray(sizes?.sizes?.size), downloaded_original: downloadedOriginal, }, raw: { info, exif, comments, contexts, sizes, }, }; await writeJson(metadataFile, metadata); const index = await readJson(FILES.photoIndex, {}); index[photoId] = { id: photoId, title, path: path.relative(DIRS.root, metadataFile), original: downloadedOriginal?.path || null, date_taken: photoStub.datetaken || "", date_upload: photoStub.dateupload || "", owner_id: ownerId, albums: resolvedSets.map((s) => ({ id: s.id, title: s.title || s.info?.title || "" })), groups: resolvedPools.map((g) => ({ id: g.id || g.nsid, name: g.title || g.name || g.info?.name || "" })), }; await writeJson(FILES.photoIndex, index); state.completed_photo_ids[photoId] = { completed_at: new Date().toISOString(), metadata_file: path.relative(DIRS.root, metadataFile), }; state.processed += 1; await saveState(state); await appendLog( "backup.log", `[${new Date().toISOString()}] OK ${photoId} ${JSON.stringify({ title, original: downloadedOriginal?.path || null, })}` ); return { ok: true };}async function downloadPhotosetsList(token) { try { const data = await flickrCall("flickr.photosets.getList", { user_id: USER_ID }, token); await writeJson(FILES.photosetList, data); } catch (err) { await appendLog("errors.log", `[${new Date().toISOString()}] photosets.getList ${err.message}`); }}async function backupFlow() { await ensureDirs(); const tokens = await getTokens(); if (!tokens?.oauth_token || !tokens?.oauth_token_secret) { console.error("No access token found. Run: node backup-flickr.js auth"); process.exit(1); } const token = { oauth_token: tokens.oauth_token, oauth_token_secret: tokens.oauth_token_secret, }; const state = await getState(); await downloadPhotosetsList(token); console.log(`Backing up Flickr user ${USER_ID} into ./${ROOT_DIR}`); console.log(`Resuming at page ${state.page}`); while (true) { const pageData = await buildPhotoListPage(state.page, state.per_page, token); const pageInfo = pageData.photos; state.total_pages = Number(pageInfo.pages || 0); state.total_photos = Number(pageInfo.total || 0); await saveState(state); const photos = toArray(pageInfo.photo); if (photos.length === 0) break; console.log( `Page ${state.page}/${state.total_pages} - ${photos.length} photos (processed ${state.processed}/${state.total_photos})` ); for (const photo of photos) { const photoId = String(photo.id); try { await processPhoto(photo, token, state); await sleep(350); } catch (err) { state.failed_photo_ids[photoId] = { failed_at: new Date().toISOString(), error: err.message, page: state.page, }; await saveState(state); await appendLog("errors.log", `[${new Date().toISOString()}] photo ${photoId} ${err.message}`); console.error(`Failed photo ${photoId}: ${err.message}`); await sleep(1000); } } if (state.page >= state.total_pages) break; state.page += 1; await saveState(state); // Gentle throttle to stay well under rate limits await sleep(1500); } console.log("\nBackup complete."); console.log(`Processed: ${state.processed}`); console.log(`Total reported by Flickr: ${state.total_photos}`); console.log(`Failed: ${Object.keys(state.failed_photo_ids).length}`);}async function main() { const cmd = process.argv[2]; if (!cmd || !["auth", "backup"].includes(cmd)) { console.log("Usage:"); console.log(" node backup-flickr.js auth"); console.log(" node backup-flickr.js backup"); process.exit(1); } try { if (cmd === "auth") { await authFlow(); } else if (cmd === "backup") { await backupFlow(); } } catch (err) { console.error(err.stack || err.message || String(err)); process.exit(1); }}main();
Run it
node backup-flickr.js authnode backup-flickr.js backup
What it creates
Inside dedricmauriac/ you’ll get a structure like this:
dedricmauriac/ _cache/ tokens.json state.json people-cache.json set-cache.json group-cache.json photo-index.json photosets-list.json _logs/ backup.log errors.log albums/ <photoset-id>.json groups/ <group-id>.json people/ <nsid>.json photos/ <photo-id>/ metadata.json original.jpg
The Quiet Relief of Possession
One of the more surprising parts of this process isn’t technical at all.
Now that the images and metadata are safely stored locally, there’s a noticeable psychological shift.
For years, that archive lived across a patchwork of online services. Some disappeared entirely. Others changed ownership, policies, or access rules. Each time a platform shut down or altered its terms of service, there was always a small lingering thought in the back of my mind:
“I should probably grab that data before it disappears.”
It wasn’t an urgent feeling, but it was persistent — a kind of background task that never quite finished.
With this recovery complete, that background noise is gone.
The photos, the metadata, the comments, the connections between them — they’re all now in my possession. They’re no longer dependent on a single company, a login system, or a policy change somewhere on the internet.
For the first time in years, the archive feels stable.
There’s a quiet relief in knowing that the history of those moments — the experiments, the events, the friendships, the communities — isn’t at risk of vanishing because a platform disappears.
The internet of the late 2000s was full of services that promised permanence but often proved temporary.
Recovering this archive doesn’t bring those platforms back.
But it does mean the memories they once carried are no longer at their mercy.
And that brings a small but genuine sense of peace of mind.
What Happens Next
With the archive now safely stored locally, the next step is deciding how to reconstruct the original posts.
The photos and conversations were originally spread across several platforms:
- the WordPress blog at dedricmauriac.wordpress.com
- Flickr comments
- SnapZilla discussions
- blogHUD images embedded in posts
Because those services disappeared at different times, much of the conversation around the images is now fragmented. Some of it was preserved through the Internet Archive’s Wayback Machine.
Possible next steps include:
- repairing broken images on the WordPress blog
- consolidating comments from multiple platforms
- publishing a unified archive on my personal site
For now, the most important step is complete.
The images and metadata have been recovered.
The images of what was once scattered across disappearing platforms now exists again as a local archive.
The race to retrieve the data is over.
Now the real work begins: rebuilding the story those images once told.
A New Discovery
Just as I was beginning to feel a sense of closure after recovering the Flickr archive, another discovery complicated the picture.
While browsing the Internet Archive, I found captures of my old Snapzilla (SLpics) pages.
Snapzilla was one of the earlier services I used to store Second Life snapshots before Flickr became part of my workflow. As I browsed through those archived pages, I noticed something surprising.
The Snapzilla archive references 3,434 images.
That’s more than double the number of photos recovered from Flickr.
At first that seemed encouraging. It suggested that a much larger portion of the archive might still exist somewhere.
But the situation turned out to be more complicated.
Only some of the Snapzilla pages were preserved, and many of the images themselves are missing. In many cases the Internet Archive captured the page layout and metadata, but not the underlying image files.
The result is a strange mixture of preserved pages with broken image links.
In other words, the structure of the archive survived, but much of the visual content did not.
Visibility and Preservation
One possible reason that any of these pages survived at all may be related to how active my Snapzilla account was at the time.
My posts on SL Universe eventually accumulated over 1.5 million views, and at one point one of my images even received a “Best of Snapzilla” award.
That level of visibility likely meant my pages were linked to frequently across the web. Pages that are referenced often tend to be crawled more regularly by archival systems like the Internet Archive.
Even so, the archive is far from complete.
Some pages were captured. Others weren’t. Some images remain intact while others are missing entirely.
The Internet Archive preserved fragments of the record, but not the full collection.
A Fragmented Digital Past
This is a reminder of how fragile online history can be.
In the late 2000s my snapshots lived simultaneously across multiple platforms:
- Snapzilla
- Flickr
- BlogHUD
- WordPress
- various social networks
Each platform captured a different slice of the story. Some duplicated it.
As those services disappeared or changed ownership, the historical record became scattered across whatever platforms happened to survive — or happened to be archived at the right moment.
Recovering the Flickr archive now feels like a major success.
But the Snapzilla discovery is a reminder that the full history of those years may never exist in one place again.
Instead, it survives in pieces.
One Last Attempt
While digging deeper into the history of SnapZilla, I discovered that SL Universe, the community where SnapZilla was hosted, still exists today under a new name:
VirtualVerse One Forums
Because SnapZilla operated within that community, there’s a small possibility that some of the original image data or backups may still exist somewhere behind the scenes.
As a bit of a last-ditch effort, I reached out to the administrators to ask whether it might be possible to obtain an export of my old SnapZilla photos.
I don’t know whether anything still exists. SnapZilla shut down many years ago, and the infrastructure that supported it may be long gone. But it felt worth asking.
If there’s even a small chance that the remaining images survived in an old backup or archive, it would be incredible to recover them.
If not, then the Flickr archive will remain the most complete surviving collection of those snapshots.
Either way, the effort feels worthwhile.
Sometimes digital archaeology involves careful technical work. Other times, it simply means reaching out and asking the people who might still remember where the data lived.







2 responses to “Recovering the Dedric Mauriac Flickr Archive”
[…] I exported the entire Flickr archive from Dedric Mauriac‘s account. Today I began turning that collection into something far more […]
[…] Recovering the Dedric Mauriac Flickr Archive […]