Files
quak/src/backup.ts
T
sneak d4098c711a Decrypt and persist all file metadata layers
Extends RawEnteFile and EnteFile with optional magicMetadata and
pubMagicMetadata fields. Both are secretstream blobs under the file
key, decrypted to arbitrary JSON (Record<string, unknown>).

pubMagicMetadata carries ML-derived data from the Ente clients:
camera make/model, image dimensions, datetime with timezone offset,
and (when present) captions, editedName, face labels, keywords.

magicMetadata carries private mutable fields like visibility.

Backup now writes per-file JSON at originals/<fileID>.json containing
all three metadata layers (basic + magic + pubMagic).

Live-tested: all 11 files in the dev account have pubMagicMetadata
with SONY DSC-RX1RM3 camera info and 3000x2000 dimensions.
2026-05-13 19:07:16 -07:00

156 lines
5.0 KiB
TypeScript

import {
existsSync,
mkdirSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { join, relative, extname } from "node:path";
import type { Client } from "./client.js";
import type { EnteFile } from "./model/types.js";
export interface BackupError {
fileID: number;
title: string;
collection: string;
error: string;
}
export interface BackupResult {
totalFiles: number;
downloaded: number;
skipped: number;
failed: number;
errors: BackupError[];
}
export type ProgressCallback = (message: string) => void;
const sanitizePath = (name: string): string =>
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_");
const originalFileName = (file: EnteFile): string => {
const ext = extname(file.metadata.title || "") || ".bin";
return `${file.id}${ext}`;
};
export const runBackup = async (
client: Client,
outDir: string,
onProgress?: ProgressCallback,
): Promise<BackupResult> => {
const log = onProgress ?? (() => {});
mkdirSync(outDir, { recursive: true });
const originalsDir = join(outDir, "originals");
mkdirSync(originalsDir, { recursive: true });
const collectionsDir = join(outDir, "collections");
mkdirSync(collectionsDir, { recursive: true });
log("Fetching collections...");
const collections = await client.listCollections();
const downloadedIDs = new Set<number>();
let totalFiles = 0;
let downloaded = 0;
let skipped = 0;
let failed = 0;
const errors: BackupError[] = [];
for (const col of collections) {
const colDirName = sanitizePath(col.name || `collection-${col.id}`);
const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true });
log(`[${col.name}] Fetching file list...`);
const files = await client.listFiles(col.id, col.key);
log(`[${col.name}] ${files.length} file(s)`);
const collectionMeta: {
id: number;
name: string;
type: string;
files: { id: number; metadata: EnteFile["metadata"] }[];
} = {
id: col.id,
name: col.name,
type: col.type,
files: [],
};
for (const file of files) {
totalFiles++;
const origName = originalFileName(file);
const origPath = join(originalsDir, origName);
const linkName = sanitizePath(
file.metadata.title || `file-${file.id}`,
);
const linkPath = join(colDir, linkName);
if (!downloadedIDs.has(file.id)) {
if (existsSync(origPath) && statSync(origPath).size > 0) {
skipped++;
downloadedIDs.add(file.id);
} else {
try {
log(`[${col.name}] Downloading ${linkName}...`);
await client.downloadFile(file, origPath);
downloaded++;
downloadedIDs.add(file.id);
} catch (err) {
log(
`[${col.name}] FAILED ${linkName}: ${err instanceof Error ? err.message : err}`,
);
failed++;
errors.push({
fileID: file.id,
title: file.metadata.title,
collection: col.name,
error:
err instanceof Error
? err.message
: String(err),
});
continue;
}
}
}
// Write per-file metadata JSON alongside the original
const metaJsonPath = join(originalsDir, `${file.id}.json`);
if (!existsSync(metaJsonPath)) {
const fileMeta: Record<string, unknown> = {
id: file.id,
collectionID: file.collectionID,
ownerID: file.ownerID,
metadata: file.metadata,
};
if (file.magicMetadata) {
fileMeta.magicMetadata = file.magicMetadata;
}
if (file.pubMagicMetadata) {
fileMeta.pubMagicMetadata = file.pubMagicMetadata;
}
writeFileSync(metaJsonPath, JSON.stringify(fileMeta, null, 2));
}
if (!existsSync(linkPath) && existsSync(origPath)) {
const target = relative(colDir, origPath);
symlinkSync(target, linkPath);
}
collectionMeta.files.push({
id: file.id,
metadata: file.metadata,
});
}
writeFileSync(
join(collectionsDir, `${colDirName}.json`),
JSON.stringify(collectionMeta, null, 2),
);
}
return { totalFiles, downloaded, skipped, failed, errors };
};