diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1d9b34e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,110 @@ +--- +# 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', 'go1.21'] + + 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 + + - job_name: go1.21 + os: ubuntu-latest + go: '1.21.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: Integration tests + shell: bash + run: | + ./integration_test.sh + if: matrix.integrationtest + + - name: Code quality test + uses: golangci/golangci-lint-action@v6 + with: + # Version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version + 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..48dcb6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*~ +/gphotosdl +/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.yml b/.goreleaser.yml new file mode 100644 index 0000000..9ed186d --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,47 @@ +# This is an example goreleaser.yaml file with some sane defaults. +# Make sure to check the documentation at http://goreleaser.com +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 + rlcp: true + 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: + name_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..ed50a55 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/rclone/gphotosdl + +go 1.21 + +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..ce8ee5c --- /dev/null +++ b/main.go @@ -0,0 +1,414 @@ +// 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 +) + +// 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) { + 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 + } + + 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