From b4b8f743c05395a3491513b61888774a6f1c0cb2 Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Tue, 17 Sep 2024 09:19:49 +0100 Subject: [PATCH] An http proxy to download full resolution Google Photos for use with rclone --- .github/workflows/build.yml | 96 +++++++ .github/workflows/goreleaser.yaml | 30 +++ .gitignore | 5 + .golangci.yml | 141 ++++++++++ .goreleaser.yaml | 47 ++++ LICENSE | 21 ++ README.md | 57 ++++ RELEASE.md | 19 ++ go.mod | 13 + go.sum | 16 ++ main.go | 424 ++++++++++++++++++++++++++++++ signals_other.go | 9 + signals_unix.go | 10 + 13 files changed, 888 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/goreleaser.yaml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .goreleaser.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 RELEASE.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 signals_other.go create mode 100644 signals_unix.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ee28b58 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,96 @@ +--- +# Github Actions build for swift +# -*- compile-command: "yamllint -f parsable build.yml" -*- + +name: build + +# Trigger the workflow on push or pull request +on: + push: + branches: + - '*' + tags: + - '*' + pull_request: + workflow_dispatch: + inputs: + manual: + required: true + default: true + +jobs: + build: + if: ${{ github.repository == 'rclone/gphotosdl' || github.event.inputs.manual }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + job_name: ['go1.23', 'go1.22'] + + include: + - job_name: go1.23 + os: ubuntu-latest + go: '1.23.x' + gotests: true + integrationtest: true + check: true + + - job_name: go1.22 + os: ubuntu-latest + go: '1.22.x' + gotests: true + integrationtest: true + check: false + + name: ${{ matrix.job_name }} + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + + - name: Print Go version and environment + shell: bash + run: | + printf "Using go at: $(which go)\n" + printf "Go version: $(go version)\n" + printf "\n\nGo environment:\n\n" + go env + printf "\n\nSystem environment:\n\n" + env + + - name: Go module cache + uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + ~/.cache/golangci-lint + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Build + shell: bash + run: | + go build ./... + + - name: Unit tests + shell: bash + run: | + go test -v + if: matrix.gotests + + - name: Code quality test + uses: golangci/golangci-lint-action@v6 + with: + version: latest + if: matrix.check diff --git a/.github/workflows/goreleaser.yaml b/.github/workflows/goreleaser.yaml new file mode 100644 index 0000000..5408ed6 --- /dev/null +++ b/.github/workflows/goreleaser.yaml @@ -0,0 +1,30 @@ +name: goreleaser + +on: + pull_request: + push: + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - + name: Set up Go + uses: actions/setup-go@v5 + - + name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d86b21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*~ +/gphotosdl +/dist/ + +dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..93583ea --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,141 @@ +# golangci-lint configuration options + +linters: + enable: + - errcheck + - goimports + - revive + - ineffassign + - govet + - unconvert + - staticcheck + - gosimple + - stylecheck + - unused + - misspell + - gocritic + #- prealloc + #- maligned + disable-all: true + +issues: + # Enable some lints excluded by default + exclude-use-default: false + + # Maximum issues count per one linter. Set to 0 to disable. Default is 50. + max-issues-per-linter: 0 + + # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. + max-same-issues: 0 + + exclude-rules: + + - linters: + - staticcheck + text: 'SA1019: "github.com/rclone/rclone/cmd/serve/httplib" is deprecated' + + # don't disable the revive messages about comments on exported functions + include: + - EXC0012 + - EXC0013 + - EXC0014 + - EXC0015 + +run: + # timeout for analysis, e.g. 30s, 5m, default is 1m + timeout: 10m + +linters-settings: + revive: + # setting rules seems to disable all the rules, so re-enable them here + rules: + - name: blank-imports + disabled: false + - name: context-as-argument + disabled: false + - name: context-keys-type + disabled: false + - name: dot-imports + disabled: false + - name: empty-block + disabled: true + - name: error-naming + disabled: false + - name: error-return + disabled: false + - name: error-strings + disabled: false + - name: errorf + disabled: false + - name: exported + disabled: false + - name: increment-decrement + disabled: true + - name: indent-error-flow + disabled: false + - name: package-comments + disabled: false + - name: range + disabled: false + - name: receiver-naming + disabled: false + - name: redefines-builtin-id + disabled: true + - name: superfluous-else + disabled: true + - name: time-naming + disabled: false + - name: unexported-return + disabled: false + - name: unreachable-code + disabled: true + - name: unused-parameter + disabled: true + - name: var-declaration + disabled: false + - name: var-naming + disabled: false + stylecheck: + # Only enable the checks performed by the staticcheck stand-alone tool, + # as documented here: https://staticcheck.io/docs/configuration/options/#checks + checks: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022", "-ST1023"] + gocritic: + # Enable all default checks with some exceptions and some additions (commented). + # Cannot use both enabled-checks and disabled-checks, so must specify all to be used. + disable-all: true + enabled-checks: + #- appendAssign # Enabled by default + - argOrder + - assignOp + - badCall + - badCond + #- captLocal # Enabled by default + - caseOrder + - codegenComment + #- commentFormatting # Enabled by default + - defaultCaseOrder + - deprecatedComment + - dupArg + - dupBranchBody + - dupCase + - dupSubExpr + - elseif + #- exitAfterDefer # Enabled by default + - flagDeref + - flagName + #- ifElseChain # Enabled by default + - mapKey + - newDeref + - offBy1 + - regexpMust + - ruleguard # Not enabled by default + #- singleCaseSwitch # Enabled by default + - sloppyLen + - sloppyTypeAssert + - switchTrue + - typeSwitchVar + - underef + - unlambda + - unslice + - valSwap + - wrapperFunc diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..9f24823 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,47 @@ +# Release the Go binary +version: 2 + +before: + hooks: + # You may remove this if you don't use go modules. + - go mod download + # you may remove this if you don't need go generate + - go generate ./... +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + - freebsd + - netbsd + - openbsd + goarch: + - amd64 + - 386 + - arm + - arm64 +archives: + - + format: zip + files: + - README.md + - LICENSE + name_template: >- + {{- .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end -}} +checksum: + name_template: 'checksums.txt' +snapshot: + version_template: "{{ .Tag }}-beta" +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a5ded5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Rclone Services Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..36c52c5 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Google Photos Downloader for rclone + +This is a Google Photos downloader for use with rclone. + +The Google Photos API delivers images and video which aren't full resolution, and/or have EXIF data missing (see [#112096115](https://issuetracker.google.com/issues/112096115) and [#113672044](https://issuetracker.google.com/issues/113672044)) + +However if you use this proxy then you can download original, unchanged images as uploaded by you. + +This runs a headless browser in the background with an HTTP server which [rclone](https://rclone.org) which uses the Google Photos website to fetch the original resolution images. + +## Usage + +First [install rclone](https://rclone.org/install/) and set it up with [google photos](https://rclone.org/googlephotos/). + +Next download the gphotosdl binary from the downloads section. + +You will need to run like this first. This will open a browser window which you should use to login to google photos - then close the browser window. You may have to do this again if the integration stops working. + + gphotosdl -login + +Once you have done this you can run this to run the proxy. + + gphotosdl + +Then supply the parameter `--gphotos-proxy "http://localhost:8282"` to make rclone use the proxy. For example + + rclone copy -vvP --gphotos-proxy "http://localhost:8282" gphotos:media/by-month/2024/2024-09/ /tmp/high-res-media/ + +Run the `gphotosdl` command with the `-debug` flag for more info and the `-show` flag to see the browser that it is using. These are essential if you are trying to debug a problem. + + gphotosdl -debug -show + +## Troubleshooting + +You can't run more than one proxy at once. If you get the error + + browser launch: [launcher] Failed to get the debug url: Opening in existing browser session. + +Then there is another `gphotosdl` running or there is an orphan browser process you will have to kill. + +## Limitations + +- Currently only fetches one image at once. Conceivable could make multiple tabs in the browser to fetch more than one at once. +- More error checking needed - if it goes wrong then it will hang forever most likely +- Currently the browser only has one profile so this can only be used with one google photos user. This is easy to fix. + +## License + +This is free software under the terms of the MIT license (check the LICENSE file included in this package). + +## Contact and support + +The project website is at: + +- https://github.com/rclone/gphotosdl + +There you can file bug reports, ask for help or contribute patches. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..7d17818 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,19 @@ +# Making a release + +Compile and test + +Then run + + goreleaser --clean --snapshot + +To test the build + +When happy, tag the release + + git tag -s -m "Release v1.0.XX" v1.0.XX + +Push to GitHub + + git push --follow-tags origin + +The github action should build the release diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0d01d65 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/rclone/gphotosdl + +go 1.22 + +require github.com/go-rod/rod v0.116.2 + +require ( + github.com/ysmood/fetchup v0.2.3 // indirect + github.com/ysmood/goob v0.4.0 // indirect + github.com/ysmood/got v0.40.0 // indirect + github.com/ysmood/gson v0.7.3 // indirect + github.com/ysmood/leakless v0.9.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ab43cb5 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= +github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg= +github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk= +github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= +github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= +github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY= +github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= +github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..1093f6a --- /dev/null +++ b/main.go @@ -0,0 +1,424 @@ +// Package main implements gphotosdl +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/input" + "github.com/go-rod/rod/lib/launcher" + "github.com/go-rod/rod/lib/proto" +) + +const ( + program = "gphotosdl" + gphotosURL = "https://photos.google.com/" + gphotoURLReal = "https://photos.google.com/photo/" + gphotoURL = "https://photos.google.com/lr/photo/" // redirects to gphotosURLReal which uses a different ID + photoID = "AF1QipNJVLe7d5mOh-b4CzFAob1UW-6EpFd0HnCBT3c6" +) + +// Flags +var ( + debug = flag.Bool("debug", false, "set to see debug messages") + login = flag.Bool("login", false, "set to launch login browser") + show = flag.Bool("show", false, "set to show the browser (not headless)") + addr = flag.String("addr", "localhost:8282", "address for the web server") + useJSON = flag.Bool("json", false, "log in JSON format") +) + +// Global variables +var ( + configRoot string // top level config dir, typically ~/.config/gphotodl + browserConfig string // work directory for browser instance + browserPath string // path to the browser binary + downloadDir string // temporary directory for downloads + browserPrefs string // JSON config for the browser + version = "DEV" // set by goreleaser + commit = "NONE" // set by goreleaser + date = "UNKNOWN" // set by goreleaser +) + +// Remove the download directory and contents +func removeDownloadDirectory() { + if downloadDir == "" { + return + } + err := os.RemoveAll(downloadDir) + if err == nil { + slog.Debug("Removed download directory") + } else { + slog.Error("Failed to remove download directory", "err", err) + } +} + +// Set up the global variables from the flags +func config() (err error) { + version := fmt.Sprintf("%s version %s, commit %s, built at %s", program, version, commit, date) + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\n%s\n", version) + } + flag.Parse() + + // Set up the logger + level := slog.LevelInfo + if *debug { + level = slog.LevelDebug + } + if *useJSON { + logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: level})) + slog.SetDefault(logger) + } else { + slog.SetLogLoggerLevel(level) // set log level of Default Handler + } + slog.Debug(version) + + configRoot, err = os.UserConfigDir() + if err != nil { + return fmt.Errorf("didn't find config directory: %w", err) + } + configRoot = filepath.Join(configRoot, program) + browserConfig = filepath.Join(configRoot, "browser") + err = os.MkdirAll(browserConfig, 0700) + if err != nil { + return fmt.Errorf("config directory creation: %w", err) + } + slog.Debug("Configured config", "config_root", configRoot, "browser_config", browserConfig) + + downloadDir, err = os.MkdirTemp("", program) + if err != nil { + log.Fatal(err) + } + slog.Debug("Created download directory", "download_directory", downloadDir) + + // Find the browser + var ok bool + browserPath, ok = launcher.LookPath() + if !ok { + return errors.New("browser not found") + } + slog.Debug("Found browser", "browser_path", browserPath) + + // Browser preferences + pref := map[string]any{ + "download": map[string]any{ + "default_directory": "/tmp/gphotos", // FIXME + }, + } + prefJSON, err := json.Marshal(pref) + if err != nil { + return fmt.Errorf("failed to make preferences: %w", err) + } + browserPrefs = string(prefJSON) + slog.Debug("made browser preferences", "prefs", browserPrefs) + + return nil +} + +// logger makes an io.Writer from slog.Debug +type logger struct{} + +// Write writes len(p) bytes from p to the underlying data stream. +func (logger) Write(p []byte) (n int, err error) { + s := string(p) + s = strings.TrimSpace(s) + slog.Debug(s) + return len(p), nil +} + +// Println is called to log text +func (logger) Println(vs ...any) { + s := fmt.Sprint(vs...) + s = strings.TrimSpace(s) + slog.Debug(s) +} + +// Gphotos is a single page browser for Google Photos +type Gphotos struct { + browser *rod.Browser + page *rod.Page + mu sync.Mutex // only one download at once is allowed +} + +// New creates a new browser on the gphotos main page to check we are logged in +func New() (*Gphotos, error) { + g := &Gphotos{} + err := g.startBrowser() + if err != nil { + return nil, err + } + err = g.startServer() + if err != nil { + return nil, err + } + return g, nil +} + +// start the browser off and check it is authenticated +func (g *Gphotos) startBrowser() error { + // We use the default profile in our new data directory + l := launcher.New(). + Bin(browserPath). + Headless(!*show). + UserDataDir(browserConfig). + Preferences(browserPrefs). + Set("disable-gpu"). + Logger(logger{}) + + url, err := l.Launch() + if err != nil { + return fmt.Errorf("browser launch: %w", err) + } + + g.browser = rod.New(). + ControlURL(url). + NoDefaultDevice(). + Trace(true). + SlowMotion(100 * time.Millisecond). + Logger(logger{}) + + err = g.browser.Connect() + if err != nil { + return fmt.Errorf("failed to connect to browser: %w", err) + } + + g.page, err = g.browser.Page(proto.TargetCreateTarget{URL: gphotosURL}) + if err != nil { + return fmt.Errorf("couldn't open gphotos URL: %w", err) + } + + eventCallback := func(e *proto.PageLifecycleEvent) { + slog.Debug("Event", "Name", e.Name, "Dump", e) + } + g.page.EachEvent(eventCallback) + + err = g.page.WaitLoad() + if err != nil { + return fmt.Errorf("gphotos page load: %w", err) + } + + authenticated := false + for try := 0; try < 60; try++ { + time.Sleep(1 * time.Second) + info := g.page.MustInfo() + slog.Debug("URL", "url", info.URL) + // When not authenticated Google redirects away from the Photos URL + if info.URL == gphotosURL { + authenticated = true + slog.Debug("Authenticated") + break + } + slog.Info("Please log in, or re-run with -login flag") + } + if !authenticated { + return errors.New("browser is not log logged in - rerun with the -login flag") + } + return nil +} + +// start the web server off +func (g *Gphotos) startServer() error { + http.HandleFunc("GET /", g.getRoot) + http.HandleFunc("GET /id/{photoID}", g.getID) + go func() { + err := http.ListenAndServe(*addr, nil) + if errors.Is(err, http.ErrServerClosed) { + slog.Debug("web server closed") + } else if err != nil { + slog.Error("Error starting web server", "err", err) + os.Exit(1) + } + }() + return nil +} + +// Serve the root page +func (g *Gphotos) getRoot(w http.ResponseWriter, r *http.Request) { + slog.Info("got / request") + _, _ = io.WriteString(w, ` + + + + + + + `+program+` + + + + +

`+program+`

+

`+program+` is used to download full resolution Google Photos in combination with rclone.

+ + +`) +} + +// Serve a photo ID +func (g *Gphotos) getID(w http.ResponseWriter, r *http.Request) { + photoID := r.PathValue("photoID") + slog.Info("got photo request", "id", photoID) + path, err := g.Download(photoID) + if err != nil { + slog.Error("Download image failed", "id", photoID, "err", err) + var h httpError + if errors.As(err, &h) { + w.WriteHeader(int(h)) + } else { + w.WriteHeader(http.StatusInternalServerError) + } + return + } + slog.Info("Downloaded photo", "id", photoID, "path", path) + + // Remove the file after it has been served + defer func() { + err := os.Remove(path) + if err == nil { + slog.Debug("Removed downloaded photo", "id", photoID, "path", path) + } else { + slog.Error("Failed to remove download directory", "id", photoID, "path", path, "err", err) + } + }() + + http.ServeFile(w, r, path) +} + +// httpError wraps an HTTP status code +type httpError int + +func (h httpError) Error() string { + return fmt.Sprintf("HTTP Error %d", h) +} + +// Download a photo with the ID given +// +// Returns the path to the photo which should be deleted after use +func (g *Gphotos) Download(photoID string) (string, error) { + // Can only download one picture at once + g.mu.Lock() + defer g.mu.Unlock() + url := gphotoURL + photoID + + var netResponse *proto.NetworkResponseReceived + + // Check the correct network request is received + waitNetwork := g.page.EachEvent(func(e *proto.NetworkResponseReceived) bool { + slog.Debug("network response", "url", e.Response.URL, "status", e.Response.Status) + if strings.HasPrefix(e.Response.URL, gphotoURLReal) { + netResponse = e + return true + } else if strings.HasPrefix(e.Response.URL, gphotoURL) { + netResponse = e + return true + } + return false + }) + + // Navigate to the photo URL + err := g.page.Navigate(url) + if err != nil { + return "", fmt.Errorf("failed to navigate to photo %q: %w", photoID, err) + } + err = g.page.WaitLoad() + if err != nil { + return "", fmt.Errorf("gphoto page load: %w", err) + } + + // Wait for the photos network request to happen + waitNetwork() + + // Print request headers + if netResponse.Response.Status != 200 { + return "", fmt.Errorf("gphoto fetch failed: %w", httpError(netResponse.Response.Status)) + } + + // Download waiter + wait := g.browser.WaitDownload(downloadDir) + + // Shift-D to download + g.page.KeyActions().Press(input.ShiftLeft).Type('D').MustDo() + + // Wait for download + info := wait() + path := filepath.Join(downloadDir, info.GUID) + + // Check file + fi, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("download failed: %w", err) + } + + slog.Debug("Download successful", "size", fi.Size(), "path", path) + + return path, nil +} + +// Close the browser +func (g *Gphotos) Close() { + err := g.browser.Close() + if err == nil { + slog.Debug("Closed browser") + } else { + slog.Error("Failed to close browser", "err", err) + } +} + +func main() { + err := config() + if err != nil { + slog.Error("Configuration failed", "err", err) + os.Exit(2) + } + defer removeDownloadDirectory() + + // If login is required, run the browser standalone + if *login { + slog.Info("Log in to google with the browser that pops up, close it, then re-run this without the -login flag") + cmd := exec.Command(browserPath, "--user-data-dir="+browserConfig, gphotosURL) + err = cmd.Start() + if err != nil { + slog.Error("Failed to start browser", "err", err) + os.Exit(2) + } + slog.Info("Waiting for browser to be closed") + err = cmd.Wait() + if err != nil { + slog.Error("Browser run failed", "err", err) + os.Exit(2) + } + slog.Info("Now restart this program without -login") + os.Exit(1) + } + + g, err := New() + if err != nil { + slog.Error("Failed to make browser", "err", err) + os.Exit(2) + } + defer g.Close() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, exitSignals...) + + // Wait for CTRL-C or SIGTERM + slog.Info("Press CTRL-C (or kill) to quit") + sig := <-quit + slog.Info("Signal received - shutting down", "signal", sig) +} diff --git a/signals_other.go b/signals_other.go new file mode 100644 index 0000000..9859517 --- /dev/null +++ b/signals_other.go @@ -0,0 +1,9 @@ +//go:build windows || plan9 + +package main + +import ( + "os" +) + +var exitSignals = []os.Signal{os.Interrupt} diff --git a/signals_unix.go b/signals_unix.go new file mode 100644 index 0000000..212bf5e --- /dev/null +++ b/signals_unix.go @@ -0,0 +1,10 @@ +//go:build !windows && !plan9 + +package main + +import ( + "os" + "syscall" +) + +var exitSignals = []os.Signal{syscall.SIGINT, syscall.SIGTERM} // Not syscall.SIGQUIT as we want the default behaviour