diff --git a/.gitignore b/.gitignore index 9cbda3cfd..94fa7cfb0 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ internal/e2e/immich-test internal/e2e/fixtures/wikimedia scratchpad/ internal/e2e/testdata +immich_test_server \ No newline at end of file diff --git a/adapters/bereal/DATA/Photos/post/1111555JKKL.webp b/adapters/bereal/DATA/Photos/post/1111555JKKL.webp new file mode 100644 index 000000000..018ea2a53 Binary files /dev/null and b/adapters/bereal/DATA/Photos/post/1111555JKKL.webp differ diff --git a/adapters/bereal/DATA/Photos/post/6lNLfy.webp b/adapters/bereal/DATA/Photos/post/6lNLfy.webp new file mode 100644 index 000000000..c85bd3ffb Binary files /dev/null and b/adapters/bereal/DATA/Photos/post/6lNLfy.webp differ diff --git a/adapters/bereal/DATA/memories.json b/adapters/bereal/DATA/memories.json new file mode 100644 index 000000000..9a2d97e99 --- /dev/null +++ b/adapters/bereal/DATA/memories.json @@ -0,0 +1,40 @@ +[ + { + "frontImage": { + "bucket": "storage.bere.al", + "height": 2688, + "width": 2016, + "path": "Photos/pN654/post/6lNLfy.webp", + "mediaType": "image", + "mimeType": "image/webp" + }, + "backImage": { + "bucket": "storage.bere.al", + "height": 2688, + "width": 2016, + "path": "Photos/pN654/post/1111555JKKL.webp", + "mediaType": "image", + "mimeType": "image/webp" + }, + "caption": "Test caption", + "isLate": false, + "date": "2025-12-12T00:00:00.000Z", + "takenTime": "2025-12-12T13:06:43.585Z", + "berealMoment": "2025-12-12T13:06:05.259Z", + "location": { + "latitude": 52.891200036865234, + "longitude": 13.000748908996582 + }, + "music": { + "track": "Vielleicht Vielleicht", + "artist": "AnnenMayKantereit", + "openUrl": "https://open.spotify.com/track/17lu3VOOdnuf6fvtj6TDL5", + "artwork": "https://i.scdn.co/image/ab67616d0000b273d7d0a73b73a6f3b0d2ce6370", + "providerId": "17", + "isrc": "DEUM71806293", + "visibility": "private", + "audioType": "track", + "provider": "spotify" + } + } +] \ No newline at end of file diff --git a/adapters/bereal/bereal.go b/adapters/bereal/bereal.go new file mode 100644 index 000000000..fd2beefd4 --- /dev/null +++ b/adapters/bereal/bereal.go @@ -0,0 +1,319 @@ +package bereal + +import ( + "context" + "encoding/json" + "fmt" + "io/fs" + "path" + "strings" + "time" + + "github.com/simulot/immich-go/app" + "github.com/simulot/immich-go/internal/assets" + "github.com/simulot/immich-go/internal/fshelper" +) + +// BeRealAdapter imports BeReal memories from an export folder +type BeRealAdapter struct { + fsys fs.FS + logger *app.Log + + memories []BeRealMemory + perMemoryAlbum bool +} + +// BeRealMemory represents a single BeReal memory entry from memories.json +type BeRealMemory struct { + FrontImage ImageInfo `json:"frontImage"` + BackImage ImageInfo `json:"backImage,omitempty"` + Caption string `json:"caption"` + IsLate bool `json:"isLate"` + Date string `json:"date"` + TakenTime string `json:"takenTime"` + BeRealMoment string `json:"berealMoment"` + Location Location `json:"location,omitempty"` + Music Music `json:"music,omitempty"` +} + +// ImageInfo contains information about a single image (main or secondary/selfie) +type ImageInfo struct { + Path string `json:"path"` + Height int `json:"height"` + Width int `json:"width"` + MediaType string `json:"mediaType"` + MimeType string `json:"mimeType"` +} + +// Location contains GPS coordinates +type Location struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + +// Music contains Spotify music information +type Music struct { + Track string `json:"track"` + Artist string `json:"artist"` +} + +// NewBeRealAdapter creates a new BeReal adapter +func NewBeRealAdapter(fsys fs.FS, logger *app.Log) (*BeRealAdapter, error) { + adapter := &BeRealAdapter{ + fsys: fsys, + logger: logger, + } + + // Load memories.json + if err := adapter.loadMemories(); err != nil { + return nil, fmt.Errorf("load memories: %w", err) + } + + return adapter, nil +} + +// SetPerMemoryAlbum enables grouping assets into per-memory albums when true. +func (ba *BeRealAdapter) SetPerMemoryAlbum(v bool) { + ba.perMemoryAlbum = v +} + +// loadMemories reads and parses memories.json +func (ba *BeRealAdapter) loadMemories() error { + // Try to find memories.json in the expected location + // BeReal exports have structure: /memories.json + memoriesPath := "memories.json" + + data, err := fs.ReadFile(ba.fsys, memoriesPath) + if err != nil { + return fmt.Errorf("read memories.json: %w", err) + } + + if err := json.Unmarshal(data, &ba.memories); err != nil { + return fmt.Errorf("parse memories.json: %w", err) + } + + ba.logger.Info(fmt.Sprintf("loaded BeReal memories: %d", len(ba.memories))) + return nil +} + +// Browse implements the adapters.Reader interface +// It returns a channel of asset groups for processing +// Front and back images from the same memory are grouped together for stacking +func (ba *BeRealAdapter) Browse(ctx context.Context) chan *assets.Group { + out := make(chan *assets.Group) + + go func() { + defer close(out) + + // Create groups where main+selfie pairs are stacked together + for _, memory := range ba.memories { + var groupAssets []*assets.Asset + + // Add Main image (back camera) - this will be the cover (first in group) + if memory.BackImage.Path != "" { + if asset := ba.createAsset(memory, true); asset != nil { + groupAssets = append(groupAssets, asset) + } + } + + // Add Selfie image (front camera) - this will be stacked + if memory.FrontImage.Path != "" { + if asset := ba.createAsset(memory, false); asset != nil { + groupAssets = append(groupAssets, asset) + } + } + + // Create a group for this memory's main+selfie camera pair + // BeReal photos should ALWAYS be stacked with main camera as cover + if len(groupAssets) > 0 { + group := assets.NewGroup(assets.GroupByDualCamera, groupAssets...) + // The main (front) image is the cover (index 0) + if len(groupAssets) > 0 { + group.SetCover(0) + } + + select { + case out <- group: + case <-ctx.Done(): + return + } + } + } + }() + + return out +} + +// createAsset converts a BeReal memory into an assets.Asset +// isMain=true for main camera (back), false for selfie camera (front) +func (ba *BeRealAdapter) createAsset(memory BeRealMemory, isMain bool) *assets.Asset { + var imageInfo ImageInfo + var tag string + + if isMain { + imageInfo = memory.BackImage + tag = "BeReal_Main" + } else { + imageInfo = memory.FrontImage + tag = "BeReal_Selfie" + } + + if imageInfo.Path == "" { + return nil + } + + // Parse the image path to get the filename + fileName := path.Base(imageInfo.Path) + + // Open the file to get metadata. Try a few fallbacks when the path in the JSON + // doesn't match the layout on disk (some exports embed the user-id as an + // extra path segment: "Photos//post/..." while files live under + // "Photos/post/..."). Build candidate paths from both the raw JSON path + // and a trimmed (no-leading-slash) variant. + var f fs.File + var err error + rawPath := imageInfo.Path + trimmed := strings.TrimPrefix(rawPath, "/") + + tryPaths := []string{} + // Start with the raw and trimmed forms (avoid duplicates) + if rawPath != "" { + tryPaths = append(tryPaths, rawPath) + } + if trimmed != rawPath { + tryPaths = append(tryPaths, trimmed) + } + + // Compute parts using the trimmed path so leading slash doesn't produce + // an empty first element. + parts := strings.Split(trimmed, "/") + // If path looks like Photos//rest -> try Photos/rest + if len(parts) >= 3 && parts[0] == "Photos" { + tryPaths = append(tryPaths, path.Join("Photos", strings.Join(parts[2:], "/"))) + } + // Also try dropping the Photos/ prefix entirely -> rest + if len(parts) >= 2 && parts[0] == "Photos" { + tryPaths = append(tryPaths, strings.Join(parts[1:], "/")) + } + + // De-duplicate tryPaths preserving order + seen := map[string]bool{} + dedup := []string{} + for _, p := range tryPaths { + if p == "" { + continue + } + if !seen[p] { + seen[p] = true + dedup = append(dedup, p) + } + } + + for _, pth := range dedup { + f, err = ba.fsys.Open(pth) + if err == nil { + imageInfo.Path = pth // record the resolved path + break + } + } + if err != nil { + ba.logger.Warn(fmt.Sprintf("failed to open BeReal image %s (tried %v): %v", imageInfo.Path, tryPaths, err)) + return nil + } + defer f.Close() + + // Get file info + info, err := f.Stat() + if err != nil { + ba.logger.Warn(fmt.Sprintf("failed to stat BeReal image %s: %v", imageInfo.Path, err)) + return nil + } + + // Parse capture date + captureDate, err := time.Parse(time.RFC3339, memory.TakenTime) + if err != nil { + ba.logger.Warn(fmt.Sprintf("failed to parse BeReal takenTime %s: %v", memory.TakenTime, err)) + // Fall back to date field + captureDate, _ = time.Parse(time.RFC3339, memory.Date) + } + + // Build description from caption if available + var description string + if memory.Caption != "" { + description = memory.Caption + } + + // Create metadata with BeReal information + // This marks the metadata as coming from the application, which is required + // for the upload process to send it to the server + md := &assets.Metadata{ + File: fshelper.FSName(ba.fsys, imageInfo.Path), + FileName: fileName, + DateTaken: captureDate, + Latitude: memory.Location.Latitude, + Longitude: memory.Location.Longitude, + Description: description, + } + + // Create asset + asset := &assets.Asset{ + File: fshelper.FSName(ba.fsys, imageInfo.Path), + OriginalFileName: fileName, + FileSize: int(info.Size()), + FileDate: info.ModTime().UTC(), + CaptureDate: captureDate, + Visibility: assets.VisibilityTimeline, + } + + // Apply metadata and mark it as from application + // This is required for location and description to be uploaded to the server + asset.FromApplication = asset.UseMetadata(md) + + // Add tag + asset.AddTag(tag) + + // Add album + if ba.perMemoryAlbum { + // Use the capture date to create a per-memory album name + albumTitle := "BeReal" + asset.Albums = []assets.Album{ + { + Title: albumTitle, + Description: "BeReal memories", + }, + } + } else { + asset.Albums = []assets.Album{ + { + Title: "BeReal", + Description: "BeReal memories", + }, + } + } + + ba.logger.Debug(fmt.Sprintf("created BeReal asset %s with tag %s dated %v, size %d", fileName, tag, captureDate, info.Size())) + + return asset +} + +// CountAssets returns the total number of assets that will be discovered +func (ba *BeRealAdapter) CountAssets() (int, error) { + count := 0 + for _, memory := range ba.memories { + if memory.FrontImage.Path != "" { + count++ + } + if memory.BackImage.Path != "" { + count++ + } + } + return count, nil +} + +// Report returns a summary of discovery +func (ba *BeRealAdapter) Report(ctx context.Context) error { + total, _ := ba.CountAssets() + fmt.Printf("BeReal: %d memories found\n", len(ba.memories)) + fmt.Printf("BeReal: %d total assets (main + selfie)\n", total) + return nil +} diff --git a/adapters/bereal/bereal_test.go b/adapters/bereal/bereal_test.go new file mode 100644 index 000000000..555530255 --- /dev/null +++ b/adapters/bereal/bereal_test.go @@ -0,0 +1,167 @@ +package bereal + +import ( + "context" + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/simulot/immich-go/app" + "github.com/simulot/immich-go/internal/assets" + "github.com/stretchr/testify/assert" +) + +func dataDirFromThisFile() string { + _, fp, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(fp), "DATA") +} + +func TestPathResolutionVariants(t *testing.T) { + dataDir := dataDirFromThisFile() + + log := &app.Log{} + log.SetLogWriter(io.Discard) + + ba, err := NewBeRealAdapter(os.DirFS(dataDir), log) + assert.NoError(t, err) + + // CountAssets should find 2 assets (front + back) + cnt, err := ba.CountAssets() + assert.NoError(t, err) + assert.Equal(t, 2, cnt) + + // Browse and ensure the asset filenames from DATA are resolved + ch := ba.Browse(context.Background()) + found := map[string]bool{} + for g := range ch { + for _, a := range g.Assets { + found[a.OriginalFileName] = true + } + } + assert.True(t, found["6lNLfy.webp"]) // front + assert.True(t, found["1111555JKKL.webp"]) // back +} + +func TestPairingCreatesTwoAssets(t *testing.T) { + dataDir := dataDirFromThisFile() + + log := &app.Log{} + log.SetLogWriter(io.Discard) + + ba, err := NewBeRealAdapter(os.DirFS(dataDir), log) + assert.NoError(t, err) + + cnt, err := ba.CountAssets() + assert.NoError(t, err) + assert.Equal(t, 2, cnt) +} + +func TestLocationIsAddedToAsset(t *testing.T) { + dataDir := dataDirFromThisFile() + + log := &app.Log{} + log.SetLogWriter(io.Discard) + + ba, err := NewBeRealAdapter(os.DirFS(dataDir), log) + assert.NoError(t, err) + + // Browse and check that location is properly added to assets + ch := ba.Browse(context.Background()) + assetCount := 0 + for g := range ch { + for _, a := range g.Assets { + assetCount++ + // Verify location coordinates are present + assert.NotZero(t, a.Latitude, "latitude should not be zero") + assert.NotZero(t, a.Longitude, "longitude should not be zero") + // Check specific expected values from test data + assert.InDelta(t, 52.8912, a.Latitude, 0.001, "latitude should match test data") + assert.InDelta(t, 13.0007, a.Longitude, 0.001, "longitude should match test data") + } + } + assert.Equal(t, 2, assetCount, "should have 2 assets with location data") +} + +func TestMetadataIsMarkedAsFromApplication(t *testing.T) { + dataDir := dataDirFromThisFile() + + log := &app.Log{} + log.SetLogWriter(io.Discard) + + ba, err := NewBeRealAdapter(os.DirFS(dataDir), log) + assert.NoError(t, err) + + // Browse and check that FromApplication metadata is set (required for location to be uploaded) + ch := ba.Browse(context.Background()) + assetCount := 0 + for g := range ch { + for _, a := range g.Assets { + assetCount++ + // The key issue: FromApplication must be set for metadata to be uploaded + // If FromApplication is nil, the location won't be sent to the server + assert.NotNil(t, a.FromApplication, "FromApplication metadata should be set for location to be uploaded") + if a.FromApplication != nil { + assert.NotZero(t, a.FromApplication.Latitude, "FromApplication latitude should not be zero") + assert.NotZero(t, a.FromApplication.Longitude, "FromApplication longitude should not be zero") + } + } + } + assert.Equal(t, 2, assetCount, "should have 2 assets with application metadata") +} + +func TestAssetsAreGroupedForStacking(t *testing.T) { + dataDir := dataDirFromThisFile() + + log := &app.Log{} + log.SetLogWriter(io.Discard) + + ba, err := NewBeRealAdapter(os.DirFS(dataDir), log) + assert.NoError(t, err) + + // Browse and check that assets are grouped together (front + back should be in same group) + ch := ba.Browse(context.Background()) + groupCount := 0 + for g := range ch { + groupCount++ + // Front and back images should be in the same group for stacking + assert.Equal(t, 2, len(g.Assets), "each group should have 2 assets (front + back)") + + // Check group type - using Grouping field instead of Type + assert.Equal(t, assets.GroupByDualCamera, g.Grouping, "group should be of type GroupByDualCamera for stacking") + + // Check that both assets have location + for i, a := range g.Assets { + assert.NotZero(t, a.Latitude, "asset %d latitude should not be zero", i) + assert.NotZero(t, a.Longitude, "asset %d longitude should not be zero", i) + } + } + assert.Equal(t, 1, groupCount, "should have 1 group (front + back pair)") +} + +func TestCaptionIsPushedAsDescription(t *testing.T) { + dataDir := dataDirFromThisFile() + + log := &app.Log{} + log.SetLogWriter(io.Discard) + + ba, err := NewBeRealAdapter(os.DirFS(dataDir), log) + assert.NoError(t, err) + + // Browse and check that caption is added to assets and marked for upload + ch := ba.Browse(context.Background()) + assetCount := 0 + for g := range ch { + for _, a := range g.Assets { + assetCount++ + // Verify description is set from the caption + assert.Equal(t, "Test caption", a.Description, "description should match the caption from memories.json") + + // Verify description is also in FromApplication (marked for server upload) + assert.NotNil(t, a.FromApplication, "FromApplication should be set") + assert.Equal(t, a.Description, a.FromApplication.Description, "description should be in FromApplication for upload to server") + } + } + assert.Equal(t, 2, assetCount, "should have 2 assets with descriptions") +} diff --git a/adapters/bereal/commands.go b/adapters/bereal/commands.go new file mode 100644 index 000000000..f4fed51f7 --- /dev/null +++ b/adapters/bereal/commands.go @@ -0,0 +1,82 @@ +package bereal + +import ( + "context" + "io/fs" + "os" + + "github.com/simulot/immich-go/adapters" + "github.com/simulot/immich-go/app" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// ImportBeRealCmd holds options for importing BeReal memories +type ImportBeRealCmd struct { + app *app.Application + BerealAlbum bool +} + +func (ibc *ImportBeRealCmd) RegisterFlags(flags *pflag.FlagSet, cmd *cobra.Command) { + // No additional flags for BeReal at this time + flags.BoolVar(&ibc.BerealAlbum, "bereal-album", false, "Group BeReal assets into per-memory albums named 'BeReal/YYYY-MM-DD'") +} + +// NewFromBeRealCommand creates the "from-bereal" subcommand for uploading BeReal memories +func NewFromBeRealCommand(ctx context.Context, parent *cobra.Command, app *app.Application, runner adapters.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "from-bereal [flags] ", + Short: "Upload BeReal memories from an export folder", + Long: `Upload BeReal memories from a BeReal export folder. +The folder should contain a 'memories.json' file and a 'Photos/bereal/' directory with the memory images.`, + Args: cobra.ExactArgs(1), + } + cmd.SetContext(ctx) + flags := cmd.Flags() + o := ImportBeRealCmd{ + app: app, + } + o.RegisterFlags(flags, cmd) + cmd.RunE = func(cmd *cobra.Command, args []string) error { //nolint:contextcheck + return o.run(cmd.Context(), cmd, args, runner) + } + + return cmd +} + +func (ibc *ImportBeRealCmd) run(ctx context.Context, cmd *cobra.Command, args []string, runner adapters.Runner) error { + sourcePath := args[0] + + // Open the source as a filesystem + // For now, we only support local directories + // The path could also be a BeReal export directory with structure: + // /memories.json + // /Photos/bereal/... + + // Try to find the memories.json at the top level or within a subfolder + dirFS := os.DirFS(sourcePath) + + // Check if memories.json exists + if _, err := fs.Stat(dirFS, "memories.json"); err != nil { + // Try looking in subdirectories (in case user points to user ID folder) + // For now, just report the error + return err + } + + // Create the adapter + adapter, err := NewBeRealAdapter(dirFS, ibc.app.Log()) + if err != nil { + return err + } + + // Apply CLI options to adapter + adapter.SetPerMemoryAlbum(ibc.BerealAlbum) + + // Report discovery results + if err := adapter.Report(ctx); err != nil { + return err + } + + // Run the upload process + return runner.Run(cmd, adapter) +} diff --git a/app/upload/upload.go b/app/upload/upload.go index aed3cec7b..c8be2fd25 100644 --- a/app/upload/upload.go +++ b/app/upload/upload.go @@ -6,6 +6,7 @@ import ( "time" "github.com/simulot/immich-go/adapters" + "github.com/simulot/immich-go/adapters/bereal" "github.com/simulot/immich-go/adapters/folder" "github.com/simulot/immich-go/adapters/fromimmich" gp "github.com/simulot/immich-go/adapters/googlePhotos" @@ -112,6 +113,7 @@ func NewUploadCommand(ctx context.Context, app *app.Application) *cobra.Command cmd.AddCommand(folder.NewFromPicasaCommand(ctx, cmd, app, uc)) cmd.AddCommand(gp.NewFromGooglePhotosCommand(ctx, cmd, app, uc)) cmd.AddCommand(fromimmich.NewFromImmichCommand(ctx, cmd, app, uc)) + cmd.AddCommand(bereal.NewFromBeRealCommand(ctx, cmd, app, uc)) cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { // Initialize the FileProcessor (tracker + logger) diff --git a/docs/README.md b/docs/README.md index baf4031d7..7fb64ec56 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ This documentation is organized into several sections to help you get started qu ### 📋 Best Practices & Advanced Topics - [**Best Practices**](best-practices.md) - Performance tips and optimization strategies +- [**Architecture**](architecture.md) - High-level design, data flow, and extension points - [**Technical Details**](technical.md) - File processing, metadata handling, and internals - [**Environment Setup**](environment.md) - Advanced environment configuration @@ -39,9 +40,10 @@ This documentation is organized into several sections to help you get started qu - [Upload from Google Photos](commands/upload.md#from-google-photos) ### Advanced Users -- [Technical Details](technical.md) for deep dive into functionality -- [Configuration](configuration.md) for advanced customization -- [Concurrency](concurrency/) for performance optimization +- [**Architecture**](architecture.md) for understanding system design and extension points +- [**Technical Details**](technical.md) for deep dive into functionality +- [**Configuration**](configuration.md) for advanced customization +- [**Concurrency**](concurrency/) for performance optimization ## 🛠 Common Commands Quick Reference @@ -69,6 +71,7 @@ docs/ ├── environment.md # Environment setup ├── examples.md # Practical examples ├── best-practices.md # Performance and reliability tips +├── architecture.md # System design and extension points ├── technical.md # Technical details and internals ├── commands/ # Command reference │ ├── README.md # Command overview diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..c8fa35ab2 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,431 @@ +# Architecture + +This document describes the high-level architecture of `immich-go`, how its components interact, and where to extend it with new features. + +## Overview + +`immich-go` is a layered application that imports photos from diverse sources and uploads them to an Immich server. It follows a **source-agnostic pipeline**: read files and metadata from an adapter → normalize to a common `Asset` model → upload to Immich → apply tags and albums. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CLI Layer (app/) │ +│ - Commands: upload, archive, stack │ +│ - Flag parsing & user interaction │ +└────────────────────┬────────────────────────────────────────┘ + │ +┌────────────────────▼────────────────────────────────────────┐ +│ Adapter Layer (adapters/) │ +│ - folder: local directory reader │ +│ - googlePhotos: Google Takeout parser │ +│ - icloud: iCloud reader │ +│ - fromimmich: server-to-server transfer │ +│ - bereal: BeReal memories import │ +└────────────────────┬────────────────────────────────────────┘ + │ +┌────────────────────▼────────────────────────────────────────┐ +│ Asset Model (internal/assets/) │ +│ - Unified Asset struct with metadata │ +│ - Metadata extraction & merging │ +└────────────────────┬────────────────────────────────────────┘ + │ +┌────────────────────▼────────────────────────────────────────┐ +│ Processing Layer (internal/) │ +│ - File type detection (filetypes) │ +│ - EXIF parsing (exif) │ +│ - Concurrency/job queue (worker) │ +│ - Duplicate detection (assettracker) │ +└────────────────────┬────────────────────────────────────────┘ + │ +┌────────────────────▼────────────────────────────────────────┐ +│ Immich Client (immich/) │ +│ - HTTP client wrapper │ +│ - API endpoints (upload, tag, album, etc.) │ +└────────────────────┬────────────────────────────────────────┘ + │ + Immich Server +``` + +## Layer Details + +### 1. CLI Layer (`app/`) + +**Responsibility**: Parse user input, orchestrate workflows, display results. + +**Key files**: +- `root/rootCmd.go` — Entry point, global flags +- `upload/upload.go` — Upload command logic +- `archive/archiveCmd.go` — Export/archive command +- `stack/stack.go` — Photo stacking command + +**Pattern**: Each command is a Cobra command with its own flow: +1. Validate flags and authentication +2. Create an adapter or client +3. Fetch/process assets +4. Call processing layer functions +5. Report results + +### 2. Adapter Layer (`adapters/`) + +**Responsibility**: Read assets and metadata from diverse sources, normalize to the internal `Asset` model. + +**Adapters**: +- **`folder/`** — Reads local filesystem + - Discovers files recursively + - Creates albums from directory names + - Pairs XMP sidecar files + +- **`googlePhotos/`** — Parses Google Takeout exports + - Extracts metadata from `.json` files + - Groups photos with their JSON companions + - Reconstructs albums and people tags + +- **`icloud/`** — Handles iCloud exports + - Reads CSV metadata + - Parses iCloud-specific file structures + - Reconstructs album info + +- **`fromimmich/`** — Fetches from another Immich server + - Uses Immich API to list assets + - Applies server-side filtering + - Re-uploads to target server + +- **`bereal/`** — Imports BeReal memories + - Parses BeReal export structure and `memories.json` + - Separates front (main) and back (selfie) camera images + - Applies proper tags (`BeReal_Main`, `BeReal_Selfie`) and stacking + - Supports per-memory album grouping + +**Interface** (implicit): +Each adapter is responsible for: +1. Discovering assets (filenames, paths) +2. Opening/reading file content +3. Extracting metadata (date, location, description, tags, albums) +4. Merging into the normalized `Asset` model + +### 3. Asset Model (`internal/assets/`) + +**Responsibility**: Unified representation of a photo/video with all metadata. + +**Key type**: `Asset` +```go +type Asset struct { + File fshelper.FSAndName // filesystem reference + OriginalFileName string // name on original source + ID string // Immich ID (after upload) + Checksum string // SHA1 hash (from Immich) + + // Core metadata + CaptureDate time.Time // when photo was taken + Description string // user description + Favorite bool // marked as favorite + Archived bool // marked as archived + Trashed bool // marked as trashed + Rating int // star rating (0-5) + + // Organization + Albums []Album // album memberships + Tags []Tag // hierarchical tags + Visibility Visibility // archive/timeline/hidden/locked + + // Location + Latitude, Longitude float64 // GPS coordinates + + // Metadata sources (for precedence/debugging) + FromSideCar *Metadata // extracted from XMP + FromSourceFile *Metadata // extracted from EXIF + FromApplication *Metadata // from JSON/CSV metadata +} +``` + +**Related types**: +- `Tag` — hierarchical tag (`Name`, `Value`) +- `Album` — album membership (`Title`, `Description`) +- `Metadata` — extracted metadata bundle + +**Usage in pipeline**: +1. Adapters populate `Asset` fields +2. Processing layer enriches/validates +3. Upload layer sends to Immich +4. Client applies tags/albums after upload + +### 4. Processing Layer (`internal/`) + +**Responsibility**: Enrich, validate, and deduplicate assets before upload. + +**Key packages**: + +- **`filetypes/`** — Detect media type from extension + - Maps `.jpg` → `image`, `.mp4` → `video` + - Validates against Immich-supported types + +- **`exif/`** — Parse EXIF metadata from files + - Extracts date taken, GPS, camera model + - Falls back to filename if EXIF missing + +- **`worker/`** — Concurrency and job queue + - Manages goroutine pool for parallelism + - Handles upload rate limiting and retries + +- **`assettracker/`** — Duplicate detection + - Tracks uploaded assets by checksum + - Prevents re-uploading same file + +- **`filenames/`** — Parse dates from filename patterns + - ISO format: `2023-07-15_14-30-25.jpg` + - Phone format: `IMG_20230715_143025.jpg` + +- **`filters/`** — Asset filtering logic + - Date range filters + - Album/tag matching + +- **`fshelper/`** — Filesystem abstraction + - Unified API for reading from zip, directory, etc. + +### 5. Immich Client (`immich/`) + +**Responsibility**: HTTP communication with Immich API. + +**Key types**: + +- `ImmichClient` — Main API wrapper + ```go + type ImmichClient struct { + baseURL string + apiKey string + http *http.Client + dryRun bool + DeviceUUID string + } + ``` + +- `Asset` — Immich's API asset representation +- `Tag`, `Album` — Immich data models +- Responses: `AssetResponse`, `TagAssetsResponse`, etc. + +**Core operations**: + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| `AssetUpload(ctx, asset)` | `POST /assets` | Upload a single asset | +| `UpsertTags(ctx, tagNames)` | `PUT /tags` | Create/get tags by name | +| `TagAssets(ctx, tagID, assetIDs)` | `PUT /tags/{id}/assets` | Apply tag to assets | +| `GetAlbums(ctx)` | `GET /albums` | List albums on server | +| `CreateAlbum(ctx, album)` | `POST /albums` | Create album | +| `AddAssetsToAlbum(ctx, albumID, assetIDs)` | `PUT /albums/{id}/assets` | Add assets to album | +| `DeleteAssets(ctx, assetIDs, force)` | `DELETE /assets` | Remove assets | + +**Special features**: +- **Dry-run mode** (`dryRun=true`): Simulates API calls, returns synthetic IDs +- **Checksum optimization**: Sends SHA1 header to detect duplicates on server +- **Multipart upload**: Streams large files with metadata in single request +- **Sidecar support**: Optionally includes XMP files with asset + +--- + +## Data Flow: Upload Example + +Here's how a photo flows through the system: + +``` +1. User runs: + immich-go upload from-folder --server=... --api-key=... /photos + +2. CLI (app/upload/) + ├─ Parse flags, authenticate + └─ Create folder adapter + +3. Adapter (adapters/folder/) + ├─ Walk filesystem recursively + ├─ Find JPEG: /photos/2023/vacation.jpg + ├─ Find XMP sidecar: /photos/2023/vacation.xmp + └─ Populate Asset: + { + File: fshelper.FSName(dirfs, "vacation.jpg"), + OriginalFileName: "vacation.jpg", + CaptureDate: parse from XMP or EXIF, + Tags: parse from XMP keywords, + Albums: ["2023", "vacation"] + } + +4. Processing (internal/) + ├─ filetypes: confirm ".jpg" → image + ├─ exif: extract EXIF date, GPS if available + ├─ assettracker: compute checksum, check for duplicates + └─ worker: queue asset for upload + +5. Upload (app/upload/) + ├─ For each asset: + │ ├─ Client.AssetUpload(asset) + │ │ └─ POST /assets with multipart: file + sidecar + metadata + │ ├─ Store returned asset ID + │ └─ Fetch or create albums/tags + │ ├─ Client.UpsertTags(["2023", "vacation"]) + │ ├─ Client.TagAssets(tagID, [assetID]) + │ └─ Client.AddAssetsToAlbum(albumID, [assetID]) + │ + └─ Report: "Uploaded 1 asset, 2 tags, 1 album" + +6. Immich Server + ├─ Stores asset + ├─ Generates thumbnails + ├─ Indexes metadata + └─ User sees photo in timeline +``` + +--- + +## Adding New Import Sources + +To add support for a new import source, follow this pattern: + +### 1. Create an Adapter (`adapters//`) + +Implement the adapter to: +- Parse the source format (filesystem, JSON metadata, CSV, API, etc.) +- Discover assets and extract metadata (dates, locations, descriptions, tags, albums) +- Normalize everything to the common `Asset` model +- Yield assets on a channel for the upload pipeline + +**Example**: The BeReal adapter discovers back/front image pairs from `memories.json`, applies tags (`BeReal_Main`, `BeReal_Selfie`), stacks them together, and optionally groups them into per-memory albums. + +### 2. Wire into CLI + +Register the command in `app/upload/upload.go` and implement the Cobra command with flags as needed. + +### 3. Leverage Immich Client + +Use existing client methods for tagging and albums — the upload orchestration handles these automatically once the adapter yields normalized assets. + +--- + +## Key Patterns + +### 1. Context Usage + +All I/O operations accept `context.Context` for cancellation and timeouts: +```go +func (ba *BeRealAdapter) DiscoverAssets(ctx context.Context) (chan *assets.Asset, error) +func (ic *ImmichClient) AssetUpload(ctx context.Context, la *assets.Asset) (AssetResponse, error) +``` + +### 2. Dry-Run Mode + +All destructive operations check `ImmichClient.dryRun`: +```go +if ic.dryRun { + return AssetResponse{ID: uuid.NewString(), Status: UploadCreated}, nil +} +``` + +Users can test workflows safely with `--dry-run` flag. + +### 3. Asset Metadata Merging + +`Asset.UseMetadata(md *Metadata)` prioritizes sources: +```go +func (a *Asset) UseMetadata(md *Metadata) *Metadata { + // Metadata fields overwrite only if currently zero/empty + a.Description = md.Description + a.CaptureDate = md.DateTaken + a.MergeAlbums(md.Albums) // append without duplicates + a.MergeTags(md.Tags) + return md +} +``` + +### 4. Filesystem Abstraction + +All adapters use `fshelper.FSAndName` (wraps `fs.FS` + filename): +- Works with directories, zip files, remote URLs equally +- Lazy-loads file content (not all in memory at once) + +### 5. Tag Hierarchy + +Tags are hierarchical strings: `"Location/USA/California/SF"` +```go +type Tag struct { + ID string // Immich tag ID + Name string // leaf name: "SF" + Value string // full path: "Location/USA/California/SF" +} + +func (a *Asset) AddTag(tag string) { + // Adds tag with leaf name auto-extracted +} +``` + +--- + +## File Organization Summary + +``` +immich-go/ +│ +├── main.go Entry point +├── app/ Commands layer +│ ├── root/ Root command +│ ├── upload/ Upload command + orchestration +│ ├── archive/ Archive command +│ └── stack/ Stacking command +│ +├── adapters/ Input adapters (source readers) +│ ├── folder/ Local folder reader +│ ├── googlePhotos/ Google Takeout parser +│ ├── icloud/ iCloud reader +│ ├── fromimmich/ Server-to-server +│ └── shared/ Common utilities (banned files, stack logic) +│ +├── immich/ Immich API client +│ ├── client.go Main client & HTTP wrapper +│ ├── asset.go Asset operations +│ ├── upload.go Multipart upload logic +│ ├── tag.go Tagging operations +│ ├── album.go Album operations +│ ├── call.go HTTP call abstraction +│ ├── bereal.go ← Your new BeReal import helper +│ └── ... (other endpoints) +│ +├── internal/ Internal utilities & processing +│ ├── assets/ Asset model & metadata +│ │ ├── asset.go Asset struct +│ │ ├── tag.go Tag struct +│ │ ├── album.go Album struct +│ │ └── metadata.go Metadata extraction +│ │ +│ ├── exif/ EXIF parsing +│ ├── filetypes/ Media type detection +│ ├── filenames/ Filename date parsing +│ ├── filters/ Filtering logic +│ ├── fshelper/ Filesystem abstraction +│ ├── worker/ Job queue & concurrency +│ ├── assettracker/ Duplicate detection +│ ├── ui/ User interface (progress bars) +│ └── ... (other utilities) +│ +└── docs/ Documentation + ├── README.md Doc hub + ├── technical.md File formats & metadata + ├── architecture.md ← This file + ├── commands/ Command reference + ├── best-practices.md Performance tips + └── ... +``` + +--- + +## Running Tests + +The project uses Go's standard testing + specialized test helpers: + +```bash +# Unit tests +go test ./... + +# E2E tests (requires running Immich server) +go test -tags=e2e ./internal/e2e/... + +# Specific adapter +go test ./adapters/bereal -v +``` + +Each adapter should include unit tests for metadata parsing and asset discovery. See `docs/test.md` for detailed guidelines. diff --git a/docs/releases/release-notes-v0.32.0.md b/docs/releases/release-notes-v0.32.0.md new file mode 100644 index 000000000..9e3dc1586 --- /dev/null +++ b/docs/releases/release-notes-v0.32.0.md @@ -0,0 +1,117 @@ +# Release Notes - v0.32.0 + +**Release Date**: December 16, 2025 + +## Overview + +This release introduces full support for importing BeReal memories with automatic back/front camera image handling, intelligent stacking, and proper tagging. + +--- + +## ✨ New Features + +### BeReal Memories Import (`from-bereal`) + +A specialized import command for BeReal memories with complete support for the dual-camera nature of BeReal photos: + +- **Automatic Camera Separation**: Separates back (main camera) and front (selfie camera) images into distinct assets +- **Smart Stacking**: Back and front images are automatically stacked together with the back camera as the cover image for viewing +- **Intelligent Tagging**: + - Back camera images tagged as `BeReal_Main` + - Front camera images tagged as `BeReal_Selfie` +- **Metadata Preservation**: + - Capture date and time extracted from BeReal export + - GPS location preserved when available + - Photo captions imported as asset descriptions +- **Album Organization**: Optional per-memory album grouping to organize photos by capture date +- **Robust Export Handling**: Handles various BeReal export formats and path variations + +**Command**: +```bash +immich-go upload from-bereal [flags] +``` + +**Flags**: +- `--bereal-album`: Group BeReal assets into the 'BeReal' album (optional) + +**Usage Example**: +```bash +immich-go upload from-bereal \ + --server=http://your-ip:2283 \ + --api-key=your-api-key \ + /path/to/bereal/export + +# With per-memory album grouping +immich-go upload from-bereal \ + --server=http://your-ip:2283 \ + --api-key=your-api-key \ + --bereal-album \ + /path/to/bereal/export +``` + +--- + +## 🚀 Improvements + +- **Architecture Enhancements**: Introduced `GroupByDualCamera` grouping type for flexible import source handling +- **Test Infrastructure**: Added E2E tests for BeReal import validation +- **Documentation**: Updated architecture documentation with BeReal as a standard adapter + +--- + +## 🔧 Internal Changes + +- Added dual-camera asset grouping support +- Enhanced metadata handling for GPS coordinates and descriptions +- Improved shell script compatibility for macOS and Linux +- Removed unused code and improved code quality + +--- + +## Compatibility + +- Requires Immich v1.106.0 or later +- Works with BeReal export format from official BeReal application +- Compatible with Windows, macOS, and Linux + +--- + +## Installation + +Download the latest release for your platform from [GitHub Releases](https://github.com/simulot/immich-go/releases/tag/v0.32.0). + +--- + +## Known Limitations + +- BeReal captions are imported as asset descriptions only (not as separate note assets) +- Per-memory album grouping groups all BeReal assets into a single 'BeReal' album (not separate albums per date) + +--- + +## Testing + +Comprehensive E2E tests verify: +- ✅ Correct asset upload (front and back images) +- ✅ Proper tagging of both camera images +- ✅ Stack creation with correct cover image +- ✅ Album organization when enabled + +--- + +## Related Issues + +Closes: #1248 — Add support for importing BeReal memories + +--- + +## Contributors + +- Implemented by: Kurisudes and Copilot +- Thanks to the Immich community for feedback and testing + +--- + +## Previous Releases + +See [Release History](../releases/) for previous versions. diff --git a/internal/assets/group.go b/internal/assets/group.go index 07f0f9d14..49ffeed50 100644 --- a/internal/assets/group.go +++ b/internal/assets/group.go @@ -7,11 +7,12 @@ import ( type GroupBy int const ( - GroupByNone GroupBy = iota - GroupByBurst // Group by burst - GroupByRawJpg // Group by raw/jpg - GroupByHeicJpg // Group by heic/jpg - GroupByOther // Group by other (same radical, not previous cases) + GroupByNone GroupBy = iota + GroupByBurst // Group by burst + GroupByRawJpg // Group by raw/jpg + GroupByHeicJpg // Group by heic/jpg + GroupByDualCamera // Group by dual-camera (e.g., BeReal front+back) + GroupByOther // Group by other (same radical, not previous cases) ) type removed struct { diff --git a/internal/e2e/client/common_test.go b/internal/e2e/client/common_test.go index 9da7d70c3..0e319d8a7 100644 --- a/internal/e2e/client/common_test.go +++ b/internal/e2e/client/common_test.go @@ -142,6 +142,8 @@ func createUser(keyName string) (user, error) { password := name u := user{Password: password, Email: email} + fmt.Printf("Creating test user: email:%s, password:%s\n", email, password) + err = e2eutils.CreateUser(admtk, email, password, email) if err != nil { return u, err diff --git a/internal/e2e/client/fromBeReal_test.go b/internal/e2e/client/fromBeReal_test.go new file mode 100644 index 000000000..1e250b53e --- /dev/null +++ b/internal/e2e/client/fromBeReal_test.go @@ -0,0 +1,53 @@ +//go:build e2e + +package client + +import ( + "testing" + + "github.com/simulot/immich-go/app/root" + e2eutils "github.com/simulot/immich-go/internal/e2e/e2eUtils" + "github.com/simulot/immich-go/internal/fileevent" +) + +func Test_FromBeReal(t *testing.T) { + adm, err := getUser("admin@immich.app") + if err != nil { + t.Fatalf("can't get admin user: %v", err) + } + // A fresh user for a new test + u1, err := createUser("minimal") + if err != nil { + t.Fatalf("can't create user: %v", err) + } + + ctx := t.Context() + c, a := root.RootImmichGoCommand(ctx) + c.SetArgs([]string{ + "upload", "from-bereal", + "--server=" + ImmichURL, + "--api-key=" + u1.APIKey, + "--admin-api-key=" + adm.APIKey, + "--no-ui", + "--log-level=debug", + ProjectDir + "/adapters/bereal/DATA", + }) + err = c.ExecuteContext(ctx) + if err != nil && a.Log().GetSLog() != nil { + a.Log().Error(err.Error()) + } + + if err != nil { + t.Error("Unexpected error", err) + return + } + + // Expecting front+back -> 2 assets; tags, album, and stacking per asset + // Verify that upload, album, tagging, and stacking events occurred + e2eutils.CheckResults(t, map[fileevent.Code]int64{ + fileevent.ProcessedUploadSuccess: 2, + fileevent.ProcessedAlbumAdded: 2, + fileevent.ProcessedTagged: 2, + fileevent.ProcessedStacked: 2, // Both assets should be stacked + }, false, a.FileProcessor()) +} diff --git a/internal/e2e/e2eUtils/getAssets.go b/internal/e2e/e2eUtils/getAssets.go index b74c8b584..f96a26315 100644 --- a/internal/e2e/e2eUtils/getAssets.go +++ b/internal/e2e/e2eUtils/getAssets.go @@ -5,29 +5,47 @@ import ( "fmt" ) +// TagResponseDto represents a tag as returned by the Immich API +type TagResponseDto struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + ParentID string `json:"parentId"` +} + +// AssetStack represents stack information for an asset +type AssetStack struct { + AssetCount int `json:"assetCount"` + ID string `json:"id"` + PrimaryAssetID string `json:"primaryAssetId"` +} + // Asset represents a simplified Immich asset returned from search type Asset struct { - ID string `json:"id"` - DeviceAssetID string `json:"deviceAssetId"` - DeviceID string `json:"deviceId"` - Type string `json:"type"` - OriginalPath string `json:"originalPath"` - OriginalFileName string `json:"originalFileName"` - Resized bool `json:"resized"` - Thumbhash string `json:"thumbhash"` - FileCreatedAt string `json:"fileCreatedAt"` - FileModifiedAt string `json:"fileModifiedAt"` - LocalDateTime string `json:"localDateTime"` - UpdatedAt string `json:"updatedAt"` - IsFavorite bool `json:"isFavorite"` - IsArchived bool `json:"isArchived"` - IsTrashed bool `json:"isTrashed"` - Duration string `json:"duration"` - Checksum string `json:"checksum"` - LivePhotoVideoID string `json:"livePhotoVideoId"` - Tags []string `json:"tags"` - Rating int `json:"rating"` - Visibility string `json:"visibility"` + ID string `json:"id"` + DeviceAssetID string `json:"deviceAssetId"` + DeviceID string `json:"deviceId"` + Type string `json:"type"` + OriginalPath string `json:"originalPath"` + OriginalFileName string `json:"originalFileName"` + Resized bool `json:"resized"` + Thumbhash string `json:"thumbhash"` + FileCreatedAt string `json:"fileCreatedAt"` + FileModifiedAt string `json:"fileModifiedAt"` + LocalDateTime string `json:"localDateTime"` + UpdatedAt string `json:"updatedAt"` + IsFavorite bool `json:"isFavorite"` + IsArchived bool `json:"isArchived"` + IsTrashed bool `json:"isTrashed"` + Duration string `json:"duration"` + Checksum string `json:"checksum"` + LivePhotoVideoID string `json:"livePhotoVideoId"` + Stack *AssetStack `json:"stack"` + Tags []TagResponseDto `json:"tags"` + Rating int `json:"rating"` + Visibility string `json:"visibility"` } // SearchMetadataRequest represents the request body for /search/metadata diff --git a/internal/e2e/e2eUtils/users.go b/internal/e2e/e2eUtils/users.go index 6c162be8d..2b4d39cfc 100644 --- a/internal/e2e/e2eUtils/users.go +++ b/internal/e2e/e2eUtils/users.go @@ -165,7 +165,6 @@ func CreateUser(adminToken Token, email string, password string, name string) er ShouldChangePassword: false, } - // uResp := map[string]any resp, err := post(getAPIURL()+"/admin/users", u, adminToken) if err != nil { return err diff --git a/readme.md b/readme.md index 518776e01..537641691 100644 --- a/readme.md +++ b/readme.md @@ -51,6 +51,7 @@ immich-go archive from-immich --server=http://your-ip:2283 --api-key=your-api-ke | [Configuration](docs/configuration.md) | Configuration options and environment variables | | [Examples](docs/examples.md) | Common use cases and practical examples | | [Best Practices](docs/best-practices.md) | Tips for optimal performance and reliability | +| [Architecture](docs/architecture.md) | Design patterns and structure of immich-go | | [Technical Details](docs/technical.md) | File processing, metadata handling, and advanced features | | [Upload Commands Overview](docs/upload-commands-overview.md) | How `immich-go` processes files from different sources | | [Release Notes](docs/releases/) | Version history and release notes | @@ -66,6 +67,7 @@ Here's a brief overview of the main upload commands: * **`from-immich`**: A server-to-server migration tool that allows you to copy assets between two Immich instances with fine-grained filtering. * **`from-picasa`**: A specialized version of `from-folder` that automatically reads `.picasa.ini` files to restore your Picasa album organization. * **`from-icloud`**: Another specialized command that handles the complexity of an iCloud Photos takeout, correctly identifying creation dates and album structures from the included CSV files. +* **`from-bereal`**: A specialized command for importing BeReal memories. Automatically separates front and back camera images into distinct assets with proper tagging (`BeReal_Main` and `BeReal_Selfie`), stacks them together for viewing, and optionally groups them by memory date into a BeReal album. Supports robust path resolution for various BeReal export formats. ### Leveraging Immich's Features diff --git a/scripts/.github/immich-api-monitor/immich-openapi-specs-baseline.json b/scripts/.github/immich-api-monitor/immich-openapi-specs-baseline.json index 3071996d8..d62cae1c7 100644 --- a/scripts/.github/immich-api-monitor/immich-openapi-specs-baseline.json +++ b/scripts/.github/immich-api-monitor/immich-openapi-specs-baseline.json @@ -10358,7 +10358,7 @@ "storageTemplateMigration": { "$ref": "#/components/schemas/JobStatusDto" }, - "thumbnailGeneration": { + "": { "$ref": "#/components/schemas/JobStatusDto" }, "videoConversion": { diff --git a/scripts/update-launch-with-key.sh b/scripts/update-launch-with-key.sh index 98da090cb..226768e47 100755 --- a/scripts/update-launch-with-key.sh +++ b/scripts/update-launch-with-key.sh @@ -3,5 +3,16 @@ set -e # Get the new API key from the e2eusers.env file ADMIN_API_KEY=$(grep "E2E_admin@immich.app_APIKEY" internal/e2e/testdata/immich-server/e2eusers.env | cut -d'=' -f2) -# Update the launch.json file with the new API key -sed -i -E "s/--api-key=([A-Za-z0-9]+)/--api-key=$ADMIN_API_KEY/" .vscode/launch.json +# Update the launch.json file with the new API key if it exists +LAUNCH_JSON=.vscode/launch.json +if [ -f "$LAUNCH_JSON" ]; then + # Use sed in-place; on macOS sed requires an empty extension for -i + if sed --version >/dev/null 2>&1; then + sed -i -E "s/--api-key=([A-Za-z0-9]+)/--api-key=$ADMIN_API_KEY/" "$LAUNCH_JSON" + else + sed -i "" -E "s/--api-key=([A-Za-z0-9]+)/--api-key=$ADMIN_API_KEY/" "$LAUNCH_JSON" + fi + echo "Updated $LAUNCH_JSON with admin API key" +else + echo "Warning: $LAUNCH_JSON not found — skipping VS Code launch update" +fi