Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add refresh-metadata command to re-upload enriched metadata

+407 -3
+2 -2
CLAUDE.md
··· 7 7 8 8 ```bash 9 9 deno task check # Type check 10 - deno task test # Run tests (54 tests) 10 + deno task test # Run tests (57 tests) 11 11 deno task lint # Lint 12 12 deno task fmt # Format 13 13 deno task fmt:check # Check formatting ··· 18 18 ``` 19 19 shared/ # @attic/shared — PhotoAsset type, S3 path helpers 20 20 cli/ # @attic/cli — commands, config, storage, manifest, export 21 - src/commands/ # init, scan, status, backup, verify, rebuild 21 + src/commands/ # init, scan, status, backup, verify, rebuild, refresh-metadata 22 22 src/config/ # Config file (load, validate, write) 23 23 src/keychain/ # macOS Keychain credential load/store 24 24 src/storage/ # Generic S3 client (provider interface + AWS SDK)
+16
README.md
··· 108 108 | `--rebuild-manifest` | Reconstruct the local manifest from S3 metadata files | 109 109 | `--bucket NAME` | Override bucket from config | 110 110 111 + ### refresh-metadata 112 + 113 + Re-upload metadata JSON for already backed-up assets without re-uploading the 114 + original files. Useful after adding new metadata fields or enrichments. 115 + 116 + ```bash 117 + deno task refresh-metadata 118 + ``` 119 + 120 + | Flag | Description | 121 + | ----------------- | -------------------------------- | 122 + | `--dry-run` | Show what would be uploaded | 123 + | `--concurrency N` | Concurrent uploads (default: 20) | 124 + | `--bucket NAME` | Override bucket from config | 125 + | `--db PATH` | Path to Photos.sqlite | 126 + 111 127 ## Configuration 112 128 113 129 Attic stores its configuration at `~/.attic/config.json` (see
+48
cli/mod.ts
··· 114 114 } 115 115 }); 116 116 117 + main 118 + .command("refresh-metadata", "Re-upload metadata JSON for backed-up assets") 119 + .option("--dry-run", "Show what would be uploaded") 120 + .option("--concurrency <n:integer>", "Concurrent uploads", { default: 20 }) 121 + .option("--bucket <name:string>", "Override bucket from config") 122 + .option("--db <path:string>", "Path to Photos.sqlite") 123 + .action(async (options: { 124 + dryRun?: boolean; 125 + concurrency: number; 126 + bucket?: string; 127 + db?: string; 128 + }) => { 129 + const { openPhotosDb } = await import("./src/photos-db/reader.ts"); 130 + const { refreshMetadata } = await import( 131 + "./src/commands/refresh-metadata.ts" 132 + ); 133 + const { createManifestStore } = await import("./src/manifest/manifest.ts"); 134 + const { createS3Provider } = await import("./src/storage/s3-client.ts"); 135 + const { loadKeychainCredentials } = await import( 136 + "./src/keychain/keychain.ts" 137 + ); 138 + 139 + const config = requireConfig(); 140 + const reader = openPhotosDb(options.db); 141 + try { 142 + const assets = reader.readAssets(); 143 + const manifestStore = createManifestStore(); 144 + const manifest = await manifestStore.load(); 145 + 146 + const credentials = await loadKeychainCredentials( 147 + config.keychain.accessKeyService, 148 + config.keychain.secretKeyService, 149 + ); 150 + const s3 = createS3Provider( 151 + credentials, 152 + options.bucket ?? config.bucket, 153 + s3ConnectionFromConfig(config), 154 + ); 155 + 156 + await refreshMetadata(assets, manifest, s3, { 157 + concurrency: options.concurrency, 158 + dryRun: options.dryRun ?? false, 159 + }); 160 + } finally { 161 + reader.close(); 162 + } 163 + }); 164 + 117 165 main.command("init", "Set up attic configuration") 118 166 .action(async () => { 119 167 const { runInit } = await import("./src/commands/init.ts");
+143
cli/src/commands/refresh-metadata.test.ts
··· 1 + import { assertEquals, assertExists } from "@std/assert"; 2 + import type { PhotoAsset } from "@attic/shared"; 3 + import { AssetKind, CloudLocalState } from "@attic/shared"; 4 + import { refreshMetadata } from "./refresh-metadata.ts"; 5 + import type { Manifest } from "../manifest/manifest.ts"; 6 + import { createMockS3Provider } from "../storage/s3-client.mock.ts"; 7 + 8 + function makeAsset( 9 + uuid: string, 10 + overrides: Partial<PhotoAsset> = {}, 11 + ): PhotoAsset { 12 + return { 13 + uuid, 14 + filename: "IMG_0001.HEIC", 15 + originalFilename: "IMG_0001.HEIC", 16 + directory: "/some/dir", 17 + dateCreated: new Date("2024-01-15T12:00:00Z"), 18 + kind: AssetKind.PHOTO, 19 + uniformTypeIdentifier: "public.heic", 20 + width: 4032, 21 + height: 3024, 22 + latitude: 52.09, 23 + longitude: 4.34, 24 + favorite: false, 25 + cloudLocalState: CloudLocalState.LOCAL, 26 + originalFileSize: 3000, 27 + originalStableHash: "abc123", 28 + title: null, 29 + description: null, 30 + albums: [], 31 + keywords: [], 32 + people: [], 33 + hasEdit: false, 34 + editedAt: null, 35 + editor: null, 36 + ...overrides, 37 + }; 38 + } 39 + 40 + function makeManifest( 41 + entries: Record< 42 + string, 43 + { s3Key: string; checksum: string; backedUpAt: string } 44 + >, 45 + ): Manifest { 46 + const manifest: Manifest = { entries: {} }; 47 + for (const [uuid, e] of Object.entries(entries)) { 48 + manifest.entries[uuid] = { uuid, ...e }; 49 + } 50 + return manifest; 51 + } 52 + 53 + Deno.test("refresh-metadata: updates metadata for backed-up assets", async () => { 54 + const assets = [ 55 + makeAsset("uuid-1", { 56 + title: "Sunset", 57 + description: "Beautiful sunset", 58 + albums: [{ uuid: "album-1", title: "Vacation" }], 59 + keywords: ["nature"], 60 + people: [{ uuid: "person-1", displayName: "Alice" }], 61 + }), 62 + makeAsset("uuid-2"), 63 + ]; 64 + 65 + const manifest = makeManifest({ 66 + "uuid-1": { 67 + s3Key: "originals/2024/01/uuid-1.heic", 68 + checksum: "sha256:abc", 69 + backedUpAt: "2024-02-01T00:00:00Z", 70 + }, 71 + "uuid-2": { 72 + s3Key: "originals/2024/01/uuid-2.heic", 73 + checksum: "sha256:def", 74 + backedUpAt: "2024-02-01T00:00:00Z", 75 + }, 76 + }); 77 + 78 + const s3 = createMockS3Provider(); 79 + const report = await refreshMetadata(assets, manifest, s3); 80 + 81 + assertEquals(report.updated, 2); 82 + assertEquals(report.failed, 0); 83 + 84 + // Verify metadata was uploaded 85 + const meta1Obj = s3.objects.get("metadata/assets/uuid-1.json"); 86 + assertExists(meta1Obj); 87 + const meta1 = JSON.parse(new TextDecoder().decode(meta1Obj.body)); 88 + assertEquals(meta1.title, "Sunset"); 89 + assertEquals(meta1.description, "Beautiful sunset"); 90 + assertEquals(meta1.albums, [{ uuid: "album-1", title: "Vacation" }]); 91 + assertEquals(meta1.keywords, ["nature"]); 92 + assertEquals(meta1.people, [{ uuid: "person-1", displayName: "Alice" }]); 93 + assertEquals(meta1.s3Key, "originals/2024/01/uuid-1.heic"); 94 + assertEquals(meta1.checksum, "sha256:abc"); 95 + assertEquals(meta1.backedUpAt, "2024-02-01T00:00:00Z"); 96 + 97 + // uuid-2 also updated with defaults 98 + const meta2Obj = s3.objects.get("metadata/assets/uuid-2.json"); 99 + assertExists(meta2Obj); 100 + const meta2 = JSON.parse(new TextDecoder().decode(meta2Obj.body)); 101 + assertEquals(meta2.title, null); 102 + assertEquals(meta2.albums, []); 103 + }); 104 + 105 + Deno.test("refresh-metadata: skips assets not in manifest", async () => { 106 + const assets = [makeAsset("uuid-1"), makeAsset("uuid-not-backed-up")]; 107 + 108 + const manifest = makeManifest({ 109 + "uuid-1": { 110 + s3Key: "originals/2024/01/uuid-1.heic", 111 + checksum: "sha256:abc", 112 + backedUpAt: "2024-02-01T00:00:00Z", 113 + }, 114 + }); 115 + 116 + const s3 = createMockS3Provider(); 117 + const report = await refreshMetadata(assets, manifest, s3); 118 + 119 + assertEquals(report.updated, 1); 120 + assertEquals( 121 + s3.objects.has("metadata/assets/uuid-not-backed-up.json"), 122 + false, 123 + ); 124 + }); 125 + 126 + Deno.test("refresh-metadata: dry run uploads nothing", async () => { 127 + const assets = [makeAsset("uuid-1")]; 128 + 129 + const manifest = makeManifest({ 130 + "uuid-1": { 131 + s3Key: "originals/2024/01/uuid-1.heic", 132 + checksum: "sha256:abc", 133 + backedUpAt: "2024-02-01T00:00:00Z", 134 + }, 135 + }); 136 + 137 + const s3 = createMockS3Provider(); 138 + const report = await refreshMetadata(assets, manifest, s3, { dryRun: true }); 139 + 140 + assertEquals(report.updated, 0); 141 + assertEquals(report.skipped, 1); 142 + assertEquals(s3.objects.size, 0); 143 + });
+196
cli/src/commands/refresh-metadata.ts
··· 1 + import type { AlbumRef, PersonRef, PhotoAsset } from "@attic/shared"; 2 + import { metadataKey } from "@attic/shared"; 3 + import type { Manifest } from "../manifest/manifest.ts"; 4 + import type { S3Provider } from "../storage/s3-client.ts"; 5 + import { formatBytes } from "../format.ts"; 6 + 7 + export interface RefreshMetadataOptions { 8 + /** Maximum concurrent uploads. */ 9 + concurrency: number; 10 + /** Show what would be uploaded without uploading. */ 11 + dryRun: boolean; 12 + } 13 + 14 + const DEFAULT_OPTIONS: RefreshMetadataOptions = { 15 + concurrency: 20, 16 + dryRun: false, 17 + }; 18 + 19 + export interface RefreshMetadataReport { 20 + updated: number; 21 + skipped: number; 22 + failed: number; 23 + totalBytes: number; 24 + errors: Array<{ uuid: string; message: string }>; 25 + } 26 + 27 + interface AssetMetadata { 28 + uuid: string; 29 + originalFilename: string; 30 + dateCreated: string | null; 31 + width: number; 32 + height: number; 33 + latitude: number | null; 34 + longitude: number | null; 35 + fileSize: number | null; 36 + type: string | null; 37 + favorite: boolean; 38 + title: string | null; 39 + description: string | null; 40 + albums: AlbumRef[]; 41 + keywords: string[]; 42 + people: PersonRef[]; 43 + hasEdit: boolean; 44 + editedAt: string | null; 45 + editor: string | null; 46 + s3Key: string; 47 + checksum: string; 48 + backedUpAt: string; 49 + } 50 + 51 + /** 52 + * Re-upload metadata JSON for already-backed-up assets. 53 + * Original files and manifest are left untouched. 54 + */ 55 + export async function refreshMetadata( 56 + assets: PhotoAsset[], 57 + manifest: Manifest, 58 + s3: S3Provider, 59 + opts: Partial<RefreshMetadataOptions> = {}, 60 + ): Promise<RefreshMetadataReport> { 61 + const options = { ...DEFAULT_OPTIONS, ...opts }; 62 + 63 + // Only refresh assets that are in the manifest 64 + const assetByUuid = new Map<string, PhotoAsset>(); 65 + for (const a of assets) { 66 + assetByUuid.set(a.uuid, a); 67 + } 68 + 69 + const toRefresh: Array< 70 + { asset: PhotoAsset; s3Key: string; checksum: string; backedUpAt: string } 71 + > = []; 72 + for (const [uuid, entry] of Object.entries(manifest.entries)) { 73 + const asset = assetByUuid.get(uuid); 74 + if (asset) { 75 + toRefresh.push({ 76 + asset, 77 + s3Key: entry.s3Key, 78 + checksum: entry.checksum, 79 + backedUpAt: entry.backedUpAt, 80 + }); 81 + } 82 + } 83 + 84 + console.log(`\n Attic — Refresh Metadata`); 85 + console.log(` ════════════════════════\n`); 86 + console.log( 87 + ` Backed-up assets in DB: ${toRefresh.length.toLocaleString()}`, 88 + ); 89 + if (options.dryRun) console.log(` Mode: DRY RUN`); 90 + console.log(); 91 + 92 + if (toRefresh.length === 0) { 93 + console.log(" Nothing to refresh — no backed-up assets found in DB.\n"); 94 + return { updated: 0, skipped: 0, failed: 0, totalBytes: 0, errors: [] }; 95 + } 96 + 97 + if (options.dryRun) { 98 + return { 99 + updated: 0, 100 + skipped: toRefresh.length, 101 + failed: 0, 102 + totalBytes: 0, 103 + errors: [], 104 + }; 105 + } 106 + 107 + const report: RefreshMetadataReport = { 108 + updated: 0, 109 + skipped: 0, 110 + failed: 0, 111 + totalBytes: 0, 112 + errors: [], 113 + }; 114 + 115 + // Process with bounded concurrency 116 + const queue = [...toRefresh]; 117 + const workers = Array.from( 118 + { length: Math.min(options.concurrency, queue.length) }, 119 + async () => { 120 + while (queue.length > 0) { 121 + const item = queue.shift()!; 122 + try { 123 + const meta = buildMetadataJson( 124 + item.asset, 125 + item.s3Key, 126 + item.checksum, 127 + item.backedUpAt, 128 + ); 129 + const data = new TextEncoder().encode( 130 + JSON.stringify(meta, null, 2), 131 + ); 132 + await s3.putObject( 133 + metadataKey(item.asset.uuid), 134 + data, 135 + "application/json", 136 + ); 137 + report.updated++; 138 + report.totalBytes += data.byteLength; 139 + } catch (error: unknown) { 140 + const msg = error instanceof Error ? error.message : "Unknown error"; 141 + report.errors.push({ uuid: item.asset.uuid, message: msg }); 142 + report.failed++; 143 + } 144 + 145 + const done = report.updated + report.failed; 146 + if (done % 50 === 0 || done === toRefresh.length) { 147 + const pct = ((done / toRefresh.length) * 100).toFixed(1); 148 + console.log( 149 + ` Progress: ${done}/${toRefresh.length} (${pct}%) ` + 150 + `Uploaded: ${formatBytes(report.totalBytes)}`, 151 + ); 152 + } 153 + } 154 + }, 155 + ); 156 + 157 + await Promise.all(workers); 158 + 159 + console.log(`\n ── Complete ──`); 160 + console.log(` Updated: ${report.updated.toLocaleString()}`); 161 + console.log(` Failed: ${report.failed.toLocaleString()}`); 162 + console.log(` Total: ${formatBytes(report.totalBytes)}\n`); 163 + 164 + return report; 165 + } 166 + 167 + function buildMetadataJson( 168 + asset: PhotoAsset, 169 + s3Key: string, 170 + checksum: string, 171 + backedUpAt: string, 172 + ): AssetMetadata { 173 + return { 174 + uuid: asset.uuid, 175 + originalFilename: asset.originalFilename ?? asset.filename, 176 + dateCreated: asset.dateCreated?.toISOString() ?? null, 177 + width: asset.width, 178 + height: asset.height, 179 + latitude: asset.latitude, 180 + longitude: asset.longitude, 181 + fileSize: asset.originalFileSize, 182 + type: asset.uniformTypeIdentifier, 183 + favorite: asset.favorite, 184 + title: asset.title, 185 + description: asset.description, 186 + albums: asset.albums, 187 + keywords: asset.keywords, 188 + people: asset.people, 189 + hasEdit: asset.hasEdit, 190 + editedAt: asset.editedAt?.toISOString() ?? null, 191 + editor: asset.editor, 192 + s3Key, 193 + checksum, 194 + backedUpAt, 195 + }; 196 + }
+2 -1
deno.json
··· 10 10 "scan": "deno run --allow-read --allow-env --allow-ffi cli/mod.ts scan", 11 11 "status": "deno run --allow-read --allow-write --allow-env --allow-ffi cli/mod.ts status", 12 12 "backup": "deno run --allow-read --allow-write --allow-env --allow-sys --allow-ffi --allow-net --allow-run cli/mod.ts backup", 13 - "verify": "deno run --allow-read --allow-write --allow-env --allow-sys --allow-net --allow-run=security cli/mod.ts verify" 13 + "verify": "deno run --allow-read --allow-write --allow-env --allow-sys --allow-net --allow-run=security cli/mod.ts verify", 14 + "refresh-metadata": "deno run --allow-read --allow-write --allow-env --allow-sys --allow-ffi --allow-net --allow-run=security cli/mod.ts refresh-metadata" 14 15 } 15 16 }