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.
156 lines
5.0 KiB
TypeScript
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 };
|
|
};
|