├── .gitignore ├── signals_other.go ├── signals_unix.go ├── RELEASE.md ├── go.mod ├── .goreleaser.yaml ├── .github └── workflows │ ├── goreleaser.yaml │ └── build.yml ├── LICENSE ├── go.sum ├── README.md ├── .golangci.yml └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | /gphotosdl 3 | /dist/ 4 | 5 | dist/ 6 | -------------------------------------------------------------------------------- /signals_other.go: -------------------------------------------------------------------------------- 1 | //go:build windows || plan9 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | ) 8 | 9 | var exitSignals = []os.Signal{os.Interrupt} 10 | -------------------------------------------------------------------------------- /signals_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !plan9 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "syscall" 8 | ) 9 | 10 | var exitSignals = []os.Signal{syscall.SIGINT, syscall.SIGTERM} // Not syscall.SIGQUIT as we want the default behaviour 11 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a release 2 | 3 | Compile and test 4 | 5 | Then run 6 | 7 | goreleaser --clean --snapshot 8 | 9 | To test the build 10 | 11 | When happy, tag the release 12 | 13 | git tag -s -m "Release v1.0.XX" v1.0.XX 14 | 15 | Push to GitHub 16 | 17 | git push --follow-tags origin 18 | 19 | The github action should build the release 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rclone/gphotosdl 2 | 3 | go 1.22 4 | 5 | require github.com/go-rod/rod v0.116.2 6 | 7 | require ( 8 | github.com/ysmood/fetchup v0.2.3 // indirect 9 | github.com/ysmood/goob v0.4.0 // indirect 10 | github.com/ysmood/got v0.40.0 // indirect 11 | github.com/ysmood/gson v0.7.3 // indirect 12 | github.com/ysmood/leakless v0.9.0 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # Release the Go binary 2 | version: 2 3 | 4 | before: 5 | hooks: 6 | # You may remove this if you don't use go modules. 7 | - go mod download 8 | # you may remove this if you don't need go generate 9 | - go generate ./... 10 | builds: 11 | - env: 12 | - CGO_ENABLED=0 13 | goos: 14 | - linux 15 | - windows 16 | - darwin 17 | - freebsd 18 | - netbsd 19 | - openbsd 20 | goarch: 21 | - amd64 22 | - 386 23 | - arm 24 | - arm64 25 | archives: 26 | - 27 | format: zip 28 | files: 29 | - README.md 30 | - LICENSE 31 | name_template: >- 32 | {{- .ProjectName }}_ 33 | {{- title .Os }}_ 34 | {{- if eq .Arch "amd64" }}x86_64 35 | {{- else if eq .Arch "386" }}i386 36 | {{- else }}{{ .Arch }}{{ end }} 37 | {{- if .Arm }}v{{ .Arm }}{{ end -}} 38 | checksum: 39 | name_template: 'checksums.txt' 40 | snapshot: 41 | version_template: "{{ .Tag }}-beta" 42 | changelog: 43 | sort: asc 44 | filters: 45 | exclude: 46 | - '^docs:' 47 | - '^test:' 48 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yaml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Set Vars 16 | if: ${{ !startsWith(github.ref, 'refs/tags/v') }} 17 | run: echo "GORELEASER_FLAGS=--snapshot" >> $GITHUB_ENV 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | - name: Run GoReleaser 25 | uses: goreleaser/goreleaser-action@v6 26 | with: 27 | distribution: goreleaser 28 | version: '~> v2' 29 | args: release --clean ${{ env.GORELEASER_FLAGS }} 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | - name: Upload all builds 33 | uses: actions/upload-artifact@v4 34 | with: 35 | name: builds 36 | path: | 37 | dist/*.zip 38 | dist/*.txt 39 | dist/*.json 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Rclone Services Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= 2 | github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= 3 | github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= 4 | github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= 5 | github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= 6 | github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= 7 | github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg= 8 | github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk= 9 | github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= 10 | github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= 11 | github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY= 12 | github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= 13 | github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= 14 | github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= 15 | github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= 16 | github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= 17 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Github Actions build for swift 3 | # -*- compile-command: "yamllint -f parsable build.yml" -*- 4 | 5 | name: build 6 | 7 | # Trigger the workflow on push or pull request 8 | on: 9 | push: 10 | branches: 11 | - '*' 12 | tags: 13 | - '*' 14 | pull_request: 15 | workflow_dispatch: 16 | inputs: 17 | manual: 18 | required: true 19 | default: true 20 | 21 | jobs: 22 | build: 23 | if: ${{ github.repository == 'rclone/gphotosdl' || github.event.inputs.manual }} 24 | timeout-minutes: 60 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | job_name: ['go1.23', 'go1.22'] 29 | 30 | include: 31 | - job_name: go1.23 32 | os: ubuntu-latest 33 | go: '1.23.x' 34 | gotests: true 35 | integrationtest: true 36 | check: true 37 | 38 | - job_name: go1.22 39 | os: ubuntu-latest 40 | go: '1.22.x' 41 | gotests: true 42 | integrationtest: true 43 | check: false 44 | 45 | name: ${{ matrix.job_name }} 46 | 47 | runs-on: ${{ matrix.os }} 48 | 49 | steps: 50 | - name: Checkout 51 | uses: actions/checkout@v4 52 | with: 53 | fetch-depth: 0 54 | 55 | - name: Install Go 56 | uses: actions/setup-go@v5 57 | with: 58 | go-version: ${{ matrix.go }} 59 | 60 | - name: Print Go version and environment 61 | shell: bash 62 | run: | 63 | printf "Using go at: $(which go)\n" 64 | printf "Go version: $(go version)\n" 65 | printf "\n\nGo environment:\n\n" 66 | go env 67 | printf "\n\nSystem environment:\n\n" 68 | env 69 | 70 | - name: Go module cache 71 | uses: actions/cache@v4 72 | with: 73 | path: | 74 | ~/go/pkg/mod 75 | ~/.cache/go-build 76 | ~/.cache/golangci-lint 77 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 78 | restore-keys: | 79 | ${{ runner.os }}-go- 80 | 81 | - name: Build 82 | shell: bash 83 | run: | 84 | go build ./... 85 | 86 | - name: Unit tests 87 | shell: bash 88 | run: | 89 | go test -v 90 | if: matrix.gotests 91 | 92 | - name: Code quality test 93 | uses: golangci/golangci-lint-action@v6 94 | with: 95 | version: latest 96 | if: matrix.check 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Google Photos Downloader for rclone 2 | 3 | This is a Google Photos downloader for use with rclone. 4 | 5 | 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)) 6 | 7 | However, if you use this proxy then you can download original, unchanged images as uploaded by you. 8 | 9 | This runs a headless browser in the background along with an HTTP server, which [rclone](https://rclone.org) uses to access the Google Photos website and fetch the original-resolution images. 10 | ## Usage 11 | 12 | First [install rclone](https://rclone.org/install/) and set it up with [google photos](https://rclone.org/googlephotos/). 13 | 14 | You will need rclone version v1.69 or later. 15 | 16 | Next download the latest gphotosdl binary from the [releases page](https://github.com/rclone/gphotosdl/releases/latest). 17 | 18 | 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. 19 | 20 | gphotosdl -login 21 | 22 | Once you have done this you can run this to run the proxy. 23 | 24 | gphotosdl 25 | 26 | Then supply the parameter `--gphotos-proxy "http://localhost:8282"` to make rclone use the proxy. For example 27 | 28 | rclone copy -vvP --gphotos-proxy "http://localhost:8282" "gPhotos:media/by-month/2024/2024-09/" "/tmp/high-res-media/" 29 | 30 | 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. 31 | 32 | gphotosdl -debug -show 33 | 34 | ## Troubleshooting 35 | 36 | You can't run more than one proxy at once. If you get the error 37 | 38 | browser launch: [launcher] Failed to get the debug url: Opening in existing browser session. 39 | 40 | Then there is another `gphotosdl` running or there is an orphan browser process you will have to kill. 41 | 42 | ## Limitations 43 | 44 | - Currently only fetches one image at once. Conceivably could make multiple tabs in the browser to fetch more than one at once. 45 | - More error checking needed - if it goes wrong then it will hang forever most likely 46 | - Currently, the browser only has one profile so this can only be used with one google photos user. This is easy to fix. 47 | 48 | ## License 49 | 50 | This is free software under the terms of the MIT license (check the LICENSE file included in this package). 51 | 52 | ## Contact and support 53 | 54 | The project website is at: 55 | 56 | - https://github.com/rclone/gphotosdl 57 | 58 | There you can file bug reports, ask for help or contribute patches. 59 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # golangci-lint configuration options 2 | 3 | linters: 4 | enable: 5 | - errcheck 6 | - goimports 7 | - revive 8 | - ineffassign 9 | - govet 10 | - unconvert 11 | - staticcheck 12 | - gosimple 13 | - stylecheck 14 | - unused 15 | - misspell 16 | - gocritic 17 | #- prealloc 18 | #- maligned 19 | disable-all: true 20 | 21 | issues: 22 | # Enable some lints excluded by default 23 | exclude-use-default: false 24 | 25 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 26 | max-issues-per-linter: 0 27 | 28 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 29 | max-same-issues: 0 30 | 31 | exclude-rules: 32 | 33 | - linters: 34 | - staticcheck 35 | text: 'SA1019: "github.com/rclone/rclone/cmd/serve/httplib" is deprecated' 36 | 37 | # don't disable the revive messages about comments on exported functions 38 | include: 39 | - EXC0012 40 | - EXC0013 41 | - EXC0014 42 | - EXC0015 43 | 44 | run: 45 | # timeout for analysis, e.g. 30s, 5m, default is 1m 46 | timeout: 10m 47 | 48 | linters-settings: 49 | revive: 50 | # setting rules seems to disable all the rules, so re-enable them here 51 | rules: 52 | - name: blank-imports 53 | disabled: false 54 | - name: context-as-argument 55 | disabled: false 56 | - name: context-keys-type 57 | disabled: false 58 | - name: dot-imports 59 | disabled: false 60 | - name: empty-block 61 | disabled: true 62 | - name: error-naming 63 | disabled: false 64 | - name: error-return 65 | disabled: false 66 | - name: error-strings 67 | disabled: false 68 | - name: errorf 69 | disabled: false 70 | - name: exported 71 | disabled: false 72 | - name: increment-decrement 73 | disabled: true 74 | - name: indent-error-flow 75 | disabled: false 76 | - name: package-comments 77 | disabled: false 78 | - name: range 79 | disabled: false 80 | - name: receiver-naming 81 | disabled: false 82 | - name: redefines-builtin-id 83 | disabled: true 84 | - name: superfluous-else 85 | disabled: true 86 | - name: time-naming 87 | disabled: false 88 | - name: unexported-return 89 | disabled: false 90 | - name: unreachable-code 91 | disabled: true 92 | - name: unused-parameter 93 | disabled: true 94 | - name: var-declaration 95 | disabled: false 96 | - name: var-naming 97 | disabled: false 98 | stylecheck: 99 | # Only enable the checks performed by the staticcheck stand-alone tool, 100 | # as documented here: https://staticcheck.io/docs/configuration/options/#checks 101 | checks: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022", "-ST1023"] 102 | gocritic: 103 | # Enable all default checks with some exceptions and some additions (commented). 104 | # Cannot use both enabled-checks and disabled-checks, so must specify all to be used. 105 | disable-all: true 106 | enabled-checks: 107 | #- appendAssign # Enabled by default 108 | - argOrder 109 | - assignOp 110 | - badCall 111 | - badCond 112 | #- captLocal # Enabled by default 113 | - caseOrder 114 | - codegenComment 115 | #- commentFormatting # Enabled by default 116 | - defaultCaseOrder 117 | - deprecatedComment 118 | - dupArg 119 | - dupBranchBody 120 | - dupCase 121 | - dupSubExpr 122 | - elseif 123 | #- exitAfterDefer # Enabled by default 124 | - flagDeref 125 | - flagName 126 | #- ifElseChain # Enabled by default 127 | - mapKey 128 | - newDeref 129 | - offBy1 130 | - regexpMust 131 | - ruleguard # Not enabled by default 132 | #- singleCaseSwitch # Enabled by default 133 | - sloppyLen 134 | - sloppyTypeAssert 135 | - switchTrue 136 | - typeSwitchVar 137 | - underef 138 | - unlambda 139 | - unslice 140 | - valSwap 141 | - wrapperFunc 142 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package main implements gphotosdl 2 | package main 3 | 4 | import ( 5 | "encoding/json" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "log" 11 | "log/slog" 12 | "net/http" 13 | "os" 14 | "os/exec" 15 | "os/signal" 16 | "path/filepath" 17 | "strings" 18 | "sync" 19 | "time" 20 | 21 | "github.com/go-rod/rod" 22 | "github.com/go-rod/rod/lib/input" 23 | "github.com/go-rod/rod/lib/launcher" 24 | "github.com/go-rod/rod/lib/proto" 25 | ) 26 | 27 | const ( 28 | program = "gphotosdl" 29 | gphotosURL = "https://photos.google.com/" 30 | loginURL = "https://photos.google.com/login" 31 | gphotoURLReal = "https://photos.google.com/photo/" 32 | gphotoURL = "https://photos.google.com/lr/photo/" // redirects to gphotosURLReal which uses a different ID 33 | photoID = "AF1QipNJVLe7d5mOh-b4CzFAob1UW-6EpFd0HnCBT3c6" 34 | ) 35 | 36 | // Flags 37 | var ( 38 | debug = flag.Bool("debug", false, "set to see debug messages") 39 | login = flag.Bool("login", false, "set to launch login browser") 40 | show = flag.Bool("show", false, "set to show the browser (not headless)") 41 | addr = flag.String("addr", "localhost:8282", "address for the web server") 42 | useJSON = flag.Bool("json", false, "log in JSON format") 43 | ) 44 | 45 | // Global variables 46 | var ( 47 | configRoot string // top level config dir, typically ~/.config/gphotodl 48 | browserConfig string // work directory for browser instance 49 | browserPath string // path to the browser binary 50 | downloadDir string // temporary directory for downloads 51 | browserPrefs string // JSON config for the browser 52 | version = "DEV" // set by goreleaser 53 | commit = "NONE" // set by goreleaser 54 | date = "UNKNOWN" // set by goreleaser 55 | ) 56 | 57 | // Remove the download directory and contents 58 | func removeDownloadDirectory() { 59 | if downloadDir == "" { 60 | return 61 | } 62 | err := os.RemoveAll(downloadDir) 63 | if err == nil { 64 | slog.Debug("Removed download directory") 65 | } else { 66 | slog.Error("Failed to remove download directory", "err", err) 67 | } 68 | } 69 | 70 | // Set up the global variables from the flags 71 | func config() (err error) { 72 | version := fmt.Sprintf("%s version %s, commit %s, built at %s", program, version, commit, date) 73 | flag.Usage = func() { 74 | fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) 75 | flag.PrintDefaults() 76 | fmt.Fprintf(os.Stderr, "\n%s\n", version) 77 | } 78 | flag.Parse() 79 | 80 | // Set up the logger 81 | level := slog.LevelInfo 82 | if *debug { 83 | level = slog.LevelDebug 84 | } 85 | if *useJSON { 86 | logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: level})) 87 | slog.SetDefault(logger) 88 | } else { 89 | slog.SetLogLoggerLevel(level) // set log level of Default Handler 90 | } 91 | slog.Debug(version) 92 | 93 | configRoot, err = os.UserConfigDir() 94 | if err != nil { 95 | return fmt.Errorf("didn't find config directory: %w", err) 96 | } 97 | configRoot = filepath.Join(configRoot, program) 98 | browserConfig = filepath.Join(configRoot, "browser") 99 | err = os.MkdirAll(browserConfig, 0700) 100 | if err != nil { 101 | return fmt.Errorf("config directory creation: %w", err) 102 | } 103 | slog.Debug("Configured config", "config_root", configRoot, "browser_config", browserConfig) 104 | 105 | downloadDir, err = os.MkdirTemp("", program) 106 | if err != nil { 107 | log.Fatal(err) 108 | } 109 | slog.Debug("Created download directory", "download_directory", downloadDir) 110 | 111 | // Find the browser 112 | var ok bool 113 | browserPath, ok = launcher.LookPath() 114 | if !ok { 115 | return errors.New("browser not found") 116 | } 117 | slog.Debug("Found browser", "browser_path", browserPath) 118 | 119 | // Browser preferences 120 | pref := map[string]any{ 121 | "download": map[string]any{ 122 | "default_directory": "/tmp/gphotos", // FIXME 123 | }, 124 | } 125 | prefJSON, err := json.Marshal(pref) 126 | if err != nil { 127 | return fmt.Errorf("failed to make preferences: %w", err) 128 | } 129 | browserPrefs = string(prefJSON) 130 | slog.Debug("made browser preferences", "prefs", browserPrefs) 131 | 132 | return nil 133 | } 134 | 135 | // logger makes an io.Writer from slog.Debug 136 | type logger struct{} 137 | 138 | // Write writes len(p) bytes from p to the underlying data stream. 139 | func (logger) Write(p []byte) (n int, err error) { 140 | s := string(p) 141 | s = strings.TrimSpace(s) 142 | slog.Debug(s) 143 | return len(p), nil 144 | } 145 | 146 | // Println is called to log text 147 | func (logger) Println(vs ...any) { 148 | s := fmt.Sprint(vs...) 149 | s = strings.TrimSpace(s) 150 | slog.Debug(s) 151 | } 152 | 153 | // Gphotos is a single page browser for Google Photos 154 | type Gphotos struct { 155 | browser *rod.Browser 156 | page *rod.Page 157 | mu sync.Mutex // only one download at once is allowed 158 | } 159 | 160 | // New creates a new browser on the gphotos main page to check we are logged in 161 | func New() (*Gphotos, error) { 162 | g := &Gphotos{} 163 | err := g.startBrowser() 164 | if err != nil { 165 | return nil, err 166 | } 167 | err = g.startServer() 168 | if err != nil { 169 | return nil, err 170 | } 171 | return g, nil 172 | } 173 | 174 | // start the browser off and check it is authenticated 175 | func (g *Gphotos) startBrowser() error { 176 | // We use the default profile in our new data directory 177 | l := launcher.New(). 178 | Bin(browserPath). 179 | Headless(!*show). 180 | UserDataDir(browserConfig). 181 | Preferences(browserPrefs). 182 | Set("disable-gpu"). 183 | Set("disable-audio-output"). 184 | Logger(logger{}) 185 | 186 | url, err := l.Launch() 187 | if err != nil { 188 | return fmt.Errorf("browser launch: %w", err) 189 | } 190 | 191 | g.browser = rod.New(). 192 | ControlURL(url). 193 | NoDefaultDevice(). 194 | Trace(true). 195 | SlowMotion(100 * time.Millisecond). 196 | Logger(logger{}) 197 | 198 | err = g.browser.Connect() 199 | if err != nil { 200 | return fmt.Errorf("failed to connect to browser: %w", err) 201 | } 202 | 203 | g.page, err = g.browser.Page(proto.TargetCreateTarget{URL: gphotosURL}) 204 | if err != nil { 205 | return fmt.Errorf("couldn't open gphotos URL: %w", err) 206 | } 207 | 208 | eventCallback := func(e *proto.PageLifecycleEvent) { 209 | slog.Debug("Event", "Name", e.Name, "Dump", e) 210 | } 211 | g.page.EachEvent(eventCallback) 212 | 213 | err = g.page.WaitLoad() 214 | if err != nil { 215 | return fmt.Errorf("gphotos page load: %w", err) 216 | } 217 | 218 | authenticated := false 219 | for try := 0; try < 60; try++ { 220 | time.Sleep(1 * time.Second) 221 | info := g.page.MustInfo() 222 | slog.Debug("URL", "url", info.URL) 223 | // When not authenticated Google redirects away from the Photos URL 224 | if info.URL == gphotosURL { 225 | authenticated = true 226 | slog.Debug("Authenticated") 227 | break 228 | } 229 | slog.Info("Please log in, or re-run with -login flag") 230 | } 231 | if !authenticated { 232 | return errors.New("browser is not log logged in - rerun with the -login flag") 233 | } 234 | return nil 235 | } 236 | 237 | // start the web server off 238 | func (g *Gphotos) startServer() error { 239 | http.HandleFunc("GET /", g.getRoot) 240 | http.HandleFunc("GET /id/{photoID}", g.getID) 241 | go func() { 242 | err := http.ListenAndServe(*addr, nil) 243 | if errors.Is(err, http.ErrServerClosed) { 244 | slog.Debug("web server closed") 245 | } else if err != nil { 246 | slog.Error("Error starting web server", "err", err) 247 | os.Exit(1) 248 | } 249 | }() 250 | return nil 251 | } 252 | 253 | // Serve the root page 254 | func (g *Gphotos) getRoot(w http.ResponseWriter, r *http.Request) { 255 | slog.Info("got / request") 256 | _, _ = io.WriteString(w, ` 257 | 258 | 259 | 260 | 261 | 262 | 263 | `+program+` 264 | 265 | 266 | 267 | 268 |

`+program+`

269 |

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

270 | 271 | 272 | `) 273 | } 274 | 275 | // Serve a photo ID 276 | func (g *Gphotos) getID(w http.ResponseWriter, r *http.Request) { 277 | photoID := r.PathValue("photoID") 278 | slog.Info("got photo request", "id", photoID) 279 | path, err := g.Download(photoID) 280 | if err != nil { 281 | slog.Error("Download image failed", "id", photoID, "err", err) 282 | var h httpError 283 | if errors.As(err, &h) { 284 | w.WriteHeader(int(h)) 285 | } else { 286 | w.WriteHeader(http.StatusInternalServerError) 287 | } 288 | return 289 | } 290 | slog.Info("Downloaded photo", "id", photoID, "path", path) 291 | 292 | // Remove the file after it has been served 293 | defer func() { 294 | err := os.Remove(path) 295 | if err == nil { 296 | slog.Debug("Removed downloaded photo", "id", photoID, "path", path) 297 | } else { 298 | slog.Error("Failed to remove download directory", "id", photoID, "path", path, "err", err) 299 | } 300 | }() 301 | 302 | http.ServeFile(w, r, path) 303 | } 304 | 305 | // httpError wraps an HTTP status code 306 | type httpError int 307 | 308 | func (h httpError) Error() string { 309 | return fmt.Sprintf("HTTP Error %d", h) 310 | } 311 | 312 | // Download a photo with the ID given 313 | // 314 | // Returns the path to the photo which should be deleted after use 315 | func (g *Gphotos) Download(photoID string) (string, error) { 316 | // Can only download one picture at once 317 | g.mu.Lock() 318 | defer g.mu.Unlock() 319 | url := gphotoURL + photoID 320 | 321 | slog := slog.With("id", photoID) 322 | 323 | // Create a new blank browser tab 324 | slog.Error("Open new tab") 325 | page, err := g.browser.Page(proto.TargetCreateTarget{}) 326 | if err != nil { 327 | return "", fmt.Errorf("failed to open browser tab for photo %q: %w", photoID, err) 328 | } 329 | defer func() { 330 | err := page.Close() 331 | if err != nil { 332 | slog.Error("Error closing tab", "Error", err) 333 | } 334 | }() 335 | 336 | var netResponse *proto.NetworkResponseReceived 337 | 338 | // Check the correct network request is received 339 | waitNetwork := page.EachEvent(func(e *proto.NetworkResponseReceived) bool { 340 | slog.Debug("network response", "rxURL", e.Response.URL, "status", e.Response.Status) 341 | if strings.HasPrefix(e.Response.URL, gphotoURLReal) { 342 | netResponse = e 343 | return true 344 | } else if strings.HasPrefix(e.Response.URL, gphotoURL) { 345 | netResponse = e 346 | return true 347 | } 348 | return false 349 | }) 350 | 351 | // Navigate to the photo URL 352 | slog.Debug("Navigate to photo URL") 353 | err = page.Navigate(url) 354 | if err != nil { 355 | return "", fmt.Errorf("failed to navigate to photo %q: %w", photoID, err) 356 | } 357 | slog.Debug("Wait for page to load") 358 | err = g.page.WaitLoad() 359 | if err != nil { 360 | return "", fmt.Errorf("gphoto page load: %w", err) 361 | } 362 | 363 | // Wait for the photos network request to happen 364 | slog.Debug("Wait for network response") 365 | waitNetwork() 366 | 367 | // Print request headers 368 | if netResponse.Response.Status != 200 { 369 | return "", fmt.Errorf("gphoto fetch failed: %w", httpError(netResponse.Response.Status)) 370 | } 371 | 372 | // Download waiter 373 | wait := g.browser.WaitDownload(downloadDir) 374 | 375 | // Urg doesn't always catch the keypress so wait 376 | time.Sleep(time.Second) 377 | 378 | // Shift-D to download 379 | page.KeyActions().Press(input.ShiftLeft).Type('D').MustDo() 380 | 381 | // Wait for download 382 | slog.Debug("Wait for download") 383 | info := wait() 384 | path := filepath.Join(downloadDir, info.GUID) 385 | 386 | // Check file 387 | fi, err := os.Stat(path) 388 | if err != nil { 389 | return "", fmt.Errorf("download failed: %w", err) 390 | } 391 | 392 | slog.Debug("Download successful", "size", fi.Size(), "path", path) 393 | 394 | return path, nil 395 | } 396 | 397 | // Close the browser 398 | func (g *Gphotos) Close() { 399 | err := g.browser.Close() 400 | if err == nil { 401 | slog.Debug("Closed browser") 402 | } else { 403 | slog.Error("Failed to close browser", "err", err) 404 | } 405 | } 406 | 407 | func main() { 408 | err := config() 409 | if err != nil { 410 | slog.Error("Configuration failed", "err", err) 411 | os.Exit(2) 412 | } 413 | defer removeDownloadDirectory() 414 | 415 | // If login is required, run the browser standalone 416 | if *login { 417 | slog.Info("Log in to google with the browser that pops up, close it, then re-run this without the -login flag") 418 | cmd := exec.Command(browserPath, "--user-data-dir="+browserConfig, loginURL) 419 | err = cmd.Start() 420 | if err != nil { 421 | slog.Error("Failed to start browser", "err", err) 422 | os.Exit(2) 423 | } 424 | slog.Info("Waiting for browser to be closed") 425 | err = cmd.Wait() 426 | if err != nil { 427 | slog.Error("Browser run failed", "err", err) 428 | os.Exit(2) 429 | } 430 | slog.Info("Now restart this program without -login") 431 | os.Exit(1) 432 | } 433 | 434 | g, err := New() 435 | if err != nil { 436 | slog.Error("Failed to make browser", "err", err) 437 | os.Exit(2) 438 | } 439 | defer g.Close() 440 | 441 | quit := make(chan os.Signal, 1) 442 | signal.Notify(quit, exitSignals...) 443 | 444 | // Wait for CTRL-C or SIGTERM 445 | slog.Info("Press CTRL-C (or kill) to quit") 446 | sig := <-quit 447 | slog.Info("Signal received - shutting down", "signal", sig) 448 | } 449 | --------------------------------------------------------------------------------