|
// proton-photos-export.js |
|
// |
|
// Bulk-export tool for Proton Photos, run from the browser DevTools console. Proton Drive/Photos |
|
// has no official bulk-export API or "download everything" button - this automates the web UI |
|
// itself: select a batch of photos, click download, wait for the zip, repeat, tracking progress |
|
// so it can resume if interrupted. |
|
// |
|
// Design choices that aren't obvious from the code alone: |
|
// |
|
// - STATE IS RESUMABLE AND CONSERVATIVE. Progress is tracked in localStorage as a set of |
|
// "confirmed downloaded" uids. An item only enters that set once this script has personally |
|
// watched its zip download complete - never on a timeout, an exception, or a best-effort |
|
// guess. Any failure mode (selection not registering, a stuck transfer, a timeout) stops the |
|
// whole run rather than marking-and-continuing, so re-running window.__startProtonExport() |
|
// always retries exactly what wasn't confirmed - nothing is silently written off as "probably |
|
// fine." |
|
// |
|
// - FIXED BATCH SIZE, NOT PER-MONTH. An earlier version grouped by calendar month, but a month |
|
// can be 3 photos or 3,000 - the latter can produce a zip large enough to strain a normal |
|
// machine's memory (this is what motivated the rewrite - an overnight run got OOM-killed). |
|
// A fixed item count per batch (MAX_ITEMS_PER_BATCH) keeps each zip's size bounded and |
|
// predictable. |
|
// |
|
// - PREFETCHING THE NEXT BATCH WHILE THE CURRENT ONE DOWNLOADS. Proton's timeline is virtualized |
|
// and its thumbnails are decrypted client-side after they render, so finding the next batch of |
|
// candidates can involve real scrolling + waiting. That scan never clicks anything, so it's run |
|
// concurrently with waiting for the current download to finish (see the main loop) instead of |
|
// serially after it - this alone cut multi-second idle gaps between batches down to near zero |
|
// in practice. |
|
// |
|
// - RE-RESOLVING PREFETCHED CARDS AGAINST THE LIVE DOM. Because that prefetch can sit around for |
|
// the length of a download, Proton's virtualization can unmount/replace a card that was |
|
// captured earlier. Clicking a stale/detached checkbox silently no-ops, which - left unchecked |
|
// - can produce a short zip that still passes the item-count sanity check (both sides of that |
|
// check would just agree on the same wrong number). Every prefetched batch is re-resolved |
|
// against the current DOM right before it's touched; any mismatch discards the whole prefetch |
|
// and re-collects fresh rather than risking a bad click. |
|
// |
|
// - FILENAMES ARE LOGGED PER BATCH so what actually landed in a zip is independently checkable |
|
// against the console log, rather than trusting an opaque success count. |
|
// |
|
// Caveats: |
|
// - This is unofficial, community-written browser automation - not affiliated with or endorsed |
|
// by Proton. It works by driving Proton Photos' current web UI via CSS selectors |
|
// (`data-testid` attributes); if Proton changes that UI, this WILL break, possibly silently. |
|
// Spot-check a handful of the zips it produces before trusting a large run, and see the |
|
// companion validate-proton-export.py / proton-photos-inventory.js for a way to independently |
|
// verify a completed run against your actual library. |
|
// - Must be delivered by DRAGGING this file onto the DevTools console panel, not pasted. Long |
|
// scripts get silently truncated/corrupted by paste in some browsers' console input, and |
|
// Proton's Content-Security-Policy blocks eval()-based delivery methods (e.g. bookmarklets). |
|
// Dragging a .js file onto the console is treated as a file load, not a paste, and sidesteps |
|
// both problems. |
|
// |
|
// Usage: |
|
// 1. Open Proton Photos in your browser, open DevTools, go to the Console tab. |
|
// 2. Drag this file onto the console panel. |
|
// 3. window.__startProtonExport() - starts from the top of the library (or wherever it left |
|
// off, if resuming). |
|
// 4. window.__stopExport = true - stop after the current batch finishes. |
|
// 5. window.__protonExportState() - see the current confirmed-downloaded count. |
|
// 6. window.__protonExportReset() - wipe all progress, start over from the top. |
|
|
|
(() => { |
|
const STATE_KEY = `protonExportStateV2`; |
|
const CARD_SELECTOR = `[data-testid="photos-card"]`; |
|
const CARD_CHECKBOX_SELECTOR = `[data-testid="photos-card-checkbox"]`; |
|
const THUMBNAIL_SELECTOR = `[data-testid="photos-card-thumbnail"]`; |
|
const SELECTED_COUNT_SELECTOR = `[data-testid="photos-selected-count"] span`; |
|
const DOWNLOAD_SELECTOR = `[data-testid="toolbar-download-selection"]`; |
|
const CLEAR_SELECTOR = `[data-testid="toolbar-clear-selection"]`; |
|
const PROGRESS_SELECTOR = `progress.tm-progress`; |
|
const CANCEL_SELECTOR = `[data-testid="drive-transfers-manager:header-controls-cancel"]`; |
|
const CLOSE_SELECTOR = `[data-testid="drive-transfers-manager:close"]`; |
|
|
|
const MAX_ITEMS_PER_BATCH = 20; |
|
const WAIT_MS = 400; |
|
const CLICK_GAP_MS = 40; |
|
const POLL_MS = 250; |
|
const MAX_SCROLL_STALLS = 6; |
|
const RENDER_RETRY_ATTEMPTS = 5; // per ensureMoreRendered() call - see its comment |
|
const MAX_PENDING_WAIT_CYCLES = 120; // 120 x POLL_MS = 30s to let the CURRENT backlog of still-decrypting cards clear before giving up on it and scrolling for more |
|
const PER_BATCH_TIMEOUT_MS = 20 * 60 * 1000; |
|
const IDLE_WAIT_TIMEOUT_MS = 20 * 60 * 1000; |
|
const GRACE_MS = 6000; |
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
|
const log = (msg) => console.log(`[proton-export] ${msg}`); |
|
|
|
function loadState() { |
|
try { |
|
const raw = localStorage.getItem(STATE_KEY); |
|
if (!raw) return { downloadedUids: [] }; |
|
const parsed = JSON.parse(raw); |
|
return { |
|
downloadedUids: Array.isArray(parsed.downloadedUids) ? parsed.downloadedUids : [], |
|
}; |
|
} catch (e) { |
|
log(`Warning: couldn't parse saved state (${e}) - starting empty.`); |
|
return { downloadedUids: [] }; |
|
} |
|
} |
|
|
|
function saveState(downloadedUids) { |
|
try { |
|
localStorage.setItem(STATE_KEY, JSON.stringify({ |
|
downloadedUids: Array.from(downloadedUids), |
|
})); |
|
} catch (e) { |
|
log(`ERROR: failed to persist state (${e}) - this batch's progress is held in memory for` + |
|
` the rest of THIS run but was NOT saved to localStorage. If this keeps happening, the` + |
|
` origin's localStorage quota is likely full.`); |
|
} |
|
} |
|
|
|
function getCardUid(card) { |
|
const img = card.querySelector(THUMBNAIL_SELECTOR); |
|
return img?.dataset?.nodeUid || null; |
|
} |
|
|
|
function getCardFilename(card) { |
|
const img = card.querySelector(THUMBNAIL_SELECTOR); |
|
const alt = img?.getAttribute(`alt`) || ``; |
|
const parts = alt.split(` - `); |
|
return parts.length ? parts[parts.length - 1] : `(unknown filename)`; |
|
} |
|
|
|
const lastCard = () => { |
|
const c = document.querySelectorAll(CARD_SELECTOR); |
|
return c.length ? c[c.length - 1] : null; |
|
}; |
|
|
|
// Scrolls the last-rendered card into view and waits for the virtualized list to mount more. |
|
// Retries with growing delays (up to RENDER_RETRY_ATTEMPTS) before giving up on THIS call, since |
|
// Proton's app can pause well over a second fetching/decrypting the next range - a single short |
|
// wait was seen to report a false "no progress" mid-scan. The caller's own MAX_SCROLL_STALLS is |
|
// still what decides "reached the true end" - this only makes each individual attempt patient. |
|
async function ensureMoreRendered() { |
|
const before = lastCard(); |
|
if (!before) return false; |
|
before.scrollIntoView({ block: `end`, behavior: `instant` }); |
|
for (let attempt = 1; attempt <= RENDER_RETRY_ATTEMPTS; attempt++) { |
|
await sleep(WAIT_MS * attempt); |
|
const after = lastCard(); |
|
if (after && after !== before) return true; |
|
} |
|
return false; |
|
} |
|
|
|
function isTransferActive() { |
|
return !!document.querySelector(CANCEL_SELECTOR); |
|
} |
|
|
|
async function waitUntilIdle(timeoutMs) { |
|
const start = Date.now(); |
|
while (Date.now() - start < timeoutMs) { |
|
if (!isTransferActive()) return true; |
|
await sleep(POLL_MS); |
|
} |
|
return false; |
|
} |
|
|
|
async function waitForDownloadComplete(timeoutMs) { |
|
const start = Date.now(); |
|
let sawRunning = false; |
|
while (Date.now() - start < timeoutMs) { |
|
if (window.__stopExport) return false; |
|
const progress = document.querySelector(PROGRESS_SELECTOR); |
|
const cancelBtn = document.querySelector(CANCEL_SELECTOR); |
|
const closeBtn = document.querySelector(CLOSE_SELECTOR); |
|
|
|
if (cancelBtn || (progress && progress.classList.contains(`progress-bar--running`))) { |
|
sawRunning = true; |
|
} |
|
|
|
const successByBar = !!(progress && progress.classList.contains(`progress-bar--success`) && progress.value >= progress.max); |
|
const successByControls = !!(closeBtn && !cancelBtn); |
|
const success = successByBar || successByControls; |
|
const elapsed = Date.now() - start; |
|
|
|
if (success && (sawRunning || elapsed > GRACE_MS)) return true; |
|
|
|
await sleep(POLL_MS); |
|
} |
|
return false; |
|
} |
|
|
|
function readSelectedCount() { |
|
const el = document.querySelector(SELECTED_COUNT_SELECTOR); |
|
if (!el) return 0; |
|
const m = el.textContent.match(/\d+/); |
|
return m ? parseInt(m[0], 10) : 0; |
|
} |
|
|
|
// Returns a per-loop async decision function for "is it safe to scroll for more content yet?". |
|
// Scrolling while cards already on screen are still mid-decrypt (no uid) only adds MORE |
|
// unresolved cards on top of the existing backlog. Wait for the CURRENT backlog to fully clear |
|
// (up to MAX_PENDING_WAIT_CYCLES) before scrolling for more, rather than advancing on a fixed |
|
// short timeout regardless of whether the backlog is shrinking or growing. |
|
function makeBacklogWaiter() { |
|
let cycles = 0; |
|
return async (pendingUidCount) => { |
|
if (pendingUidCount === 0) { |
|
cycles = 0; |
|
return `ready`; |
|
} |
|
if (cycles < MAX_PENDING_WAIT_CYCLES) { |
|
cycles++; |
|
await sleep(POLL_MS); |
|
return `wait`; |
|
} |
|
cycles = 0; |
|
return `giveup`; |
|
}; |
|
} |
|
|
|
// Scans currently-rendered cards (scrolling to render more as needed) for the next `batchSize` |
|
// items whose uid isn't already in `excludeUids`. Never scrolls for more while any rendered |
|
// card is still mid-decrypt (see makeBacklogWaiter above). Returns fewer than batchSize only |
|
// once the whole library has genuinely been walked to its end. |
|
// May run concurrently with a download (see the main loop) - it only reads the DOM and scrolls, |
|
// it never clicks anything, so it's safe to overlap. Bails early (returning whatever's been |
|
// found so far) if __stopExport flips mid-scan, so a stop during the overlap window doesn't have |
|
// to wait out a full backlog/stall cycle. |
|
async function collectNextBatch(excludeUids, batchSize) { |
|
let stalls = 0; |
|
const waitForBacklog = makeBacklogWaiter(); |
|
while (true) { |
|
if (window.__stopExport) return []; |
|
const allCards = Array.from(document.querySelectorAll(CARD_SELECTOR)); |
|
const candidates = []; |
|
let pendingUidCount = 0; |
|
for (const card of allCards) { |
|
const uid = getCardUid(card); |
|
if (!uid) { pendingUidCount++; continue; } |
|
if (!excludeUids.has(uid)) { |
|
candidates.push({ card, uid }); |
|
if (candidates.length >= batchSize) break; |
|
} |
|
} |
|
if (candidates.length >= batchSize) return candidates; |
|
|
|
const decision = await waitForBacklog(pendingUidCount); |
|
if (decision === `wait`) continue; |
|
if (decision === `giveup`) { |
|
log(`Warning: ${pendingUidCount} rendered card(s) still have no uid after ${(MAX_PENDING_WAIT_CYCLES * POLL_MS / 1000).toFixed(0)}s - skipping them this pass, they'll be re-checked next pass.`); |
|
} |
|
|
|
const progressed = await ensureMoreRendered(); |
|
if (!progressed) { |
|
stalls++; |
|
if (stalls >= MAX_SCROLL_STALLS) return candidates; |
|
} else { |
|
stalls = 0; |
|
} |
|
} |
|
} |
|
|
|
function currentCardsByUid() { |
|
const map = new Map(); |
|
for (const card of document.querySelectorAll(CARD_SELECTOR)) { |
|
const uid = getCardUid(card); |
|
if (uid) map.set(uid, card); |
|
} |
|
return map; |
|
} |
|
|
|
window.__protonExportState = () => { |
|
const s = loadState(); |
|
log(`downloaded=${s.downloadedUids.length}`); |
|
return s; |
|
}; |
|
|
|
window.__protonExportReset = () => { |
|
localStorage.removeItem(STATE_KEY); |
|
log(`State cleared. Next window.__startProtonExport() call will start from item 0.`); |
|
}; |
|
|
|
window.__stopExport = false; |
|
|
|
window.__startProtonExport = async function () { |
|
window.__stopExport = false; |
|
const s = loadState(); |
|
const downloadedUids = new Set(s.downloadedUids); |
|
|
|
log(`Starting. ${downloadedUids.size} item(s) already confirmed downloaded (batch size ${MAX_ITEMS_PER_BATCH}).`); |
|
|
|
document.querySelector(CLEAR_SELECTOR)?.click(); |
|
await sleep(WAIT_MS); |
|
const idleAtStart = await waitUntilIdle(IDLE_WAIT_TIMEOUT_MS); |
|
if (!idleAtStart) { |
|
log(`A transfer looked active at startup and never cleared - stopping before touching anything. Check the Proton tab manually.`); |
|
return; |
|
} |
|
document.querySelector(CLOSE_SELECTOR)?.click(); |
|
await sleep(WAIT_MS); |
|
|
|
let batch = await collectNextBatch(downloadedUids, MAX_ITEMS_PER_BATCH); |
|
let gapTimings = null; // set at the end of a confirmed batch, read+logged right before the next download click - see its use below |
|
|
|
while (!window.__stopExport) { |
|
if (!batch.length) { |
|
log(`No more undone items found. Reached the end of the library. Done. Total downloaded: ${downloadedUids.size}.`); |
|
break; |
|
} |
|
|
|
// Prefetched batches were collected from a snapshot of the DOM taken WHILE the previous |
|
// download was still in flight. Proton keeps re-rendering during that window (thumbnails |
|
// finishing decrypt, the transfers-manager panel opening/closing), and virtualization can |
|
// unmount/replace a card that was captured earlier. Re-resolve every uid against the CURRENT |
|
// DOM right before touching it; if any is missing, throw the whole prefetch away and |
|
// re-collect fresh rather than clicking a stale/detached checkbox (which silently no-ops and |
|
// would produce a short zip that still passes the count check below, since both sides of |
|
// that check would agree on the same wrong number). |
|
const staleCheckStart = Date.now(); |
|
const liveCards = currentCardsByUid(); |
|
const resolved = []; |
|
let stale = false; |
|
for (const item of batch) { |
|
const liveCard = liveCards.get(item.uid); |
|
if (!liveCard) { stale = true; break; } |
|
resolved.push({ card: liveCard, uid: item.uid }); |
|
} |
|
if (stale) { |
|
log(`Prefetched batch had a stale card reference (DOM changed during the previous download) - discarding and re-collecting fresh.`); |
|
batch = await collectNextBatch(downloadedUids, MAX_ITEMS_PER_BATCH); |
|
gapTimings = null; |
|
continue; |
|
} |
|
batch = resolved; |
|
const staleCheckMs = Date.now() - staleCheckStart; |
|
|
|
const batchLabel = `batch of ${batch.length}`; |
|
const idle = await waitUntilIdle(IDLE_WAIT_TIMEOUT_MS); |
|
if (!idle) { |
|
log(`${batchLabel}: a previous transfer never went idle - STOPPING. Nothing in this batch was marked downloaded. Re-run window.__startProtonExport() after checking the Proton tab manually.`); |
|
break; |
|
} |
|
|
|
const selectStart = Date.now(); |
|
const confirmed = []; |
|
for (const item of batch) { |
|
const cb = item.card.querySelector(CARD_CHECKBOX_SELECTOR); |
|
if (!cb) continue; |
|
if (!cb.checked) cb.click(); |
|
await sleep(CLICK_GAP_MS); |
|
if (cb.checked) confirmed.push(item); |
|
} |
|
const selectMs = Date.now() - selectStart; |
|
|
|
if (!confirmed.length) { |
|
log(`${batchLabel}: selection didn't register (0 confirmed checked) - STOPPING. Nothing marked downloaded.`); |
|
break; |
|
} |
|
const uiCount = readSelectedCount(); |
|
if (uiCount !== confirmed.length) { |
|
log(`${batchLabel}: UI reports ${uiCount} selected, checkbox state confirms ${confirmed.length} - STOPPING rather than trusting a mismatch. Nothing marked downloaded.`); |
|
break; |
|
} |
|
|
|
const filenames = confirmed.map((item) => getCardFilename(item.card)); |
|
log(`Selected ${confirmed.length} item(s) (${batchLabel}): ${JSON.stringify(filenames)}`); |
|
|
|
const downloadBtn = document.querySelector(DOWNLOAD_SELECTOR); |
|
if (!downloadBtn) { |
|
log(`${batchLabel}: download button missing right after selecting - STOPPING. Nothing marked downloaded.`); |
|
break; |
|
} |
|
|
|
if (gapTimings) { |
|
const totalGapMs = gapTimings.prefetchOverheadMs + gapTimings.clearCloseMs + staleCheckMs + selectMs; |
|
log(`Gap since previous download completed: prefetch-still-running=${gapTimings.prefetchOverheadMs}ms,` + |
|
` clear/close=${gapTimings.clearCloseMs}ms, stale-check=${staleCheckMs}ms, selection=${selectMs}ms,` + |
|
` total=${totalGapMs}ms.`); |
|
} |
|
|
|
downloadBtn.click(); |
|
|
|
// Overlap: scan ahead for the NEXT batch (scrolling + waiting out any decrypt backlog) |
|
// while THIS batch's zip is generating/downloading, instead of after. downloadedUids only |
|
// gets this batch's uids once the download below is CONFIRMED complete, so pass them as an |
|
// extra exclude set here to keep the prefetch from re-selecting still-in-flight items. |
|
const inFlightExclude = new Set(downloadedUids); |
|
for (const item of confirmed) inFlightExclude.add(item.uid); |
|
|
|
let downloadDoneAt = null; |
|
let prefetchDoneAt = null; |
|
const [ok, prefetched] = await Promise.all([ |
|
waitForDownloadComplete(PER_BATCH_TIMEOUT_MS).then((r) => { downloadDoneAt = Date.now(); return r; }), |
|
collectNextBatch(inFlightExclude, MAX_ITEMS_PER_BATCH).then((r) => { prefetchDoneAt = Date.now(); return r; }), |
|
]); |
|
|
|
if (!ok) { |
|
log(`${batchLabel}: did not confirm completion (timed out, or you stopped it) - STOPPING. Nothing in this batch was marked downloaded, it will be retried from the top of this batch next run.`); |
|
break; |
|
} |
|
log(`${batchLabel}: download complete. Verify the zip that just landed has ${confirmed.length} file(s) matching the names above.`); |
|
for (const item of confirmed) downloadedUids.add(item.uid); |
|
saveState(downloadedUids); |
|
|
|
// prefetchOverheadMs is 0 if the prefetch scan finished before (or same time as) the |
|
// download - i.e. it was fully hidden behind the download and cost nothing. It's positive |
|
// only when the scroll/decrypt-backlog wait for the NEXT batch outlasted the download of |
|
// THIS one - that's genuine leftover idle time even with the overlap, worth watching for. |
|
const prefetchOverheadMs = Math.max(0, (prefetchDoneAt ?? Date.now()) - (downloadDoneAt ?? Date.now())); |
|
|
|
const clearCloseStart = Date.now(); |
|
document.querySelector(CLEAR_SELECTOR)?.click(); |
|
await sleep(WAIT_MS); |
|
document.querySelector(CLOSE_SELECTOR)?.click(); |
|
await sleep(WAIT_MS); |
|
const clearCloseMs = Date.now() - clearCloseStart; |
|
|
|
gapTimings = { prefetchOverheadMs, clearCloseMs }; |
|
batch = prefetched; |
|
} |
|
|
|
log(`Stopped. Confirmed downloaded so far: ${downloadedUids.size}.`); |
|
log(`Re-running window.__startProtonExport() resumes from here - only confirmed downloads count toward that number.`); |
|
}; |
|
|
|
const initial = loadState(); |
|
log(`Loaded. Confirmed downloaded so far: ${initial.downloadedUids.length}.`); |
|
log(`Call window.__startProtonExport() to begin/resume.`); |
|
})(); |