Skip to content

Architecture

gphoto2proton follows the hexagonal (ports & adapters) architecture pattern.


Package Layout

cmd/gphoto2proton/         CLI entry point (Cobra)
├── main.go                func main() calls Execute()
├── root.go                CLI commands, flags, Execute()
├── pipeline.go            Pipeline orchestration
├── root_test.go           CLI tests
└── pipeline_test.go       Pipeline tests (mocks)

internal/                  Not importable outside this module
├── domain/                Core business types
│   ├── photo.go           Media, MediaMeta structs
│   ├── album.go           Album struct, AlbumHandler callback
│   └── migration.go       State enum (Pending → AlbumAttached)
├── port/                  Interface definitions (the "ports")
│   ├── takeout.go         TakeoutReader interface
│   ├── exif.go            ExifProcessor interface
│   ├── upload.go          ProtonUploader interface
│   └── state.go           StateTracker interface
├── takeout/               Adapter: Google Takeout streaming
│   ├── stream.go          tar/tgz streaming reader
│   └── metadata.go        JSON sidecar + album parsing
├── exif/                  Adapter: EXIF restoration
│   └── processor.go       exiftool subprocess wrapper
├── proton/                Adapter: Proton integration
│   ├── auth.go            CredentialStore (save/load/clear)
│   ├── upload.go          Proton-API-Bridge uploader
│   └── album.go           Proton Photos HTTP album client
└── state/                 Adapter: persistence
    └── sqlite.go          SQLite state tracker

Dependency Flow

graph TD
    CLI[cmd/gphoto2proton] --> Ports[internal/port]
    CLI --> Domain[internal/domain]
    Ports --> Domain

    Takeout[internal/takeout] -.->|implements| TakeoutPort[TakeoutReader]
    Exif[internal/exif] -.->|implements| ExifPort[ExifProcessor]
    Proton[internal/proton] -.->|implements| UploadPort[ProtonUploader]
    State[internal/state] -.->|implements| StatePort[StateTracker]

    TakeoutPort --> Domain
    ExifPort --> Domain
    UploadPort --> Domain
    StatePort --> Domain

Each adapter implements a port interface with zero knowledge of the CLI layer. The pipeline in cmd/gphoto2proton wires concrete adapters into the interfaces.


Pipeline Orchestration

The Pipeline struct in cmd/gphoto2proton/pipeline.go coordinates the full migration:

Pipeline.Run()
├── uploadAll()
│   └── loop: Reader.Next() → Exif.Process() → Uploader.Upload() → State.RecordFull()
├── Reader.AlbumManifest()
├── recordAlbumMembership()   → State.RecordAlbumMembership() per album/file
└── createAlbums()
    └── loop: Uploader.CreateAlbum()

Source: cmd/gphoto2proton/pipeline.go:45

type Pipeline struct {
    Reader   port.TakeoutReader
    Uploader port.ProtonUploader
    State    port.StateTracker
    Exif     port.ExifProcessor
    OnAlbums domain.AlbumHandler
    Logger   *slog.Logger
    Resume   bool
}

Port Interfaces

All interfaces are defined in internal/port/:

TakeoutReader

type TakeoutReader interface {
    Next(ctx context.Context) (*domain.Media, io.ReadCloser, error)
    AlbumManifest(ctx context.Context) ([]domain.Album, error)
}

ExifProcessor

type ExifProcessor interface {
    Process(ctx context.Context, r io.Reader, meta *domain.MediaMeta) (io.ReadCloser, error)
}

ProtonUploader

type ProtonUploader interface {
    Upload(ctx context.Context, name string, r io.Reader) (string, error)
    CreateAlbum(ctx context.Context, name string, fileIDs []string) (string, error)
}

StateTracker

type StateTracker interface {
    Init(ctx context.Context, sessionID string) error
    Record(ctx context.Context, fileID string, state domain.State) error
    RecordFull(ctx context.Context, fileID string, state domain.State, fileName string, fileSize int64, errorMsg string) error
    RecordAlbum(ctx context.Context, albumID string, state domain.State) error
    RecordAlbumMembership(ctx context.Context, albumName, fileName string) error
    AccumulatedAlbums(ctx context.Context) ([]domain.Album, error)
    FileStates(ctx context.Context, sessionID string) ([]FileEntry, error)
    Close() error
}

Testing Strategy

Each adapter is tested independently with mocked dependencies:

Package Test File Approach
takeout takeout_test.go, metadata_test.go Real tar/tgz fixtures
exif exif_test.go With/without exiftool on PATH
proton proton_test.go, album_test.go Mock HTTP servers
state state_test.go In-memory SQLite
cmd/gphoto2proton root_test.go, pipeline_test.go Mock readers/uploaders

All 126 tests pass on main. Run them with:

go test -v -race -coverprofile=coverage.out ./...