├── .github └── workflows │ ├── build-docker.yml │ └── test.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── assets └── icloud_photo_sync.jpg ├── docker-compose.yaml ├── go.mod ├── go.sum ├── icloud-photo-admin ├── .gitignore ├── .npmrc ├── .umirc.ts ├── mock │ └── api.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── src │ ├── assets │ │ └── icloud.jpg │ ├── global.css │ ├── i18n │ │ └── index.ts │ ├── layouts │ │ └── index.tsx │ ├── net │ │ └── index.ts │ └── pages │ │ ├── accountManage.tsx │ │ ├── addAccount.tsx │ │ ├── configure.tsx │ │ ├── login.tsx │ │ └── register.tsx ├── tailwind.config.js ├── tsconfig.json └── typings.d.ts ├── icloud-photo-cli ├── command │ ├── command_download.go │ ├── command_list_db.go │ ├── command_upload.go │ ├── common.go │ ├── model.go │ └── util.go └── main.go ├── icloud.go └── internal ├── auth.go ├── auth_signin.go ├── auth_trust_session.go ├── auth_trusted_deviced.go ├── auth_validate_code.go ├── auth_validate_token.go ├── auth_verify_2fa.go ├── auth_with_credentials_service.go ├── auth_with_token.go ├── client.go ├── close.go ├── config.go ├── drive_cli.go ├── error.go ├── photo_album.go ├── photo_album_photos.go ├── photo_album_photos_iter.go ├── photo_album_size.go ├── photo_asset.go ├── photo_asset_delete.go ├── photo_asset_download.go ├── photo_asset_upload.go ├── photo_cli.go ├── photo_database.go ├── photo_folder.go ├── photo_folder_create.go ├── photo_folders.go ├── request.go ├── string_input.go ├── type.go └── util.go /.github/workflows/build-docker.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | workflow_dispatch: 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | packages: write 14 | steps: 15 | - 16 | name: Checkout 17 | uses: actions/checkout@v4 18 | - 19 | name: Docker meta 20 | id: meta 21 | uses: docker/metadata-action@v5 22 | with: 23 | # list of Docker images to use as base name for tags 24 | images: | 25 | ghcr.io/chyroc/icloud-photo-cli 26 | # generate Docker tags based on the following events/attributes 27 | tags: | 28 | type=schedule 29 | type=ref,event=branch 30 | type=ref,event=pr 31 | type=semver,pattern={{version}} 32 | type=semver,pattern={{major}}.{{minor}} 33 | type=semver,pattern={{major}} 34 | type=sha 35 | - 36 | name: Set up QEMU 37 | uses: docker/setup-qemu-action@v3 38 | - 39 | name: Set up Docker Buildx 40 | uses: docker/setup-buildx-action@v3 41 | - 42 | name: Login to GHCR 43 | uses: docker/login-action@v3 44 | with: 45 | registry: ghcr.io 46 | username: ${{ github.repository_owner }} 47 | password: ${{ secrets.GITHUB_TOKEN }} 48 | - 49 | name: Build and push 50 | uses: docker/build-push-action@v5 51 | with: 52 | context: . 53 | push: ${{ github.event_name != 'pull_request' }} 54 | tags: ${{ steps.meta.outputs.tags }} 55 | labels: ${{ steps.meta.outputs.labels }} 56 | platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7,linux/arm/v8 57 | cache-from: type=gha 58 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | run: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 30 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.19 20 | 21 | - name: Build 22 | run: | 23 | go build -v ./... 24 | 25 | - name: Test 26 | run: | 27 | go test -v ./... 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | .idea/ 17 | .DS_Store 18 | ttt/ 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19 AS build 2 | 3 | ENV GOPATH /go 4 | WORKDIR /app 5 | 6 | COPY . . 7 | RUN CGO_ENABLED=0 GOOS=linux go build -o /go/bin/icloud-photo-cli ./icloud-photo-cli/main.go 8 | 9 | RUN strip /go/bin/icloud-photo-cli 10 | RUN test -e /go/bin/icloud-photo-cli 11 | 12 | FROM alpine:latest 13 | 14 | LABEL org.opencontainers.image.source=https://github.com/chyroc/icloudgo 15 | LABEL org.opencontainers.image.description="Operate iCloud Photos." 16 | LABEL org.opencontainers.image.licenses="Apache-2.0" 17 | 18 | ENV ICLOUD_USERNAME="" 19 | ENV ICLOUD_PASSWORD="" 20 | ENV ICLOUD_COOKIE_DIR="/icloud_cookie" 21 | ENV ICLOUD_DOMAIN="cn" 22 | ENV ICLOUD_OUTPUT="/icloud_photos" 23 | ENV ICLOUD_ALBUM="" 24 | ENV ICLOUD_THREAD_NUM="10" 25 | ENV ICLOUD_AUTO_DELETE="true" 26 | ENV ICLOUD_STOP_FOUND_NUM="50" 27 | ENV ICLOUD_FOLDER_STRUCTURE="2006/01/02" 28 | ENV ICLOUD_FILE_STRUCTURE="id" 29 | ENV ICLOUD_WITH_LIVE_PHOTO="true" 30 | 31 | COPY --from=build /go/bin/icloud-photo-cli /bin/icloud-photo-cli 32 | 33 | ENTRYPOINT ["/bin/icloud-photo-cli"] 34 | CMD ["help"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # icloudgo 2 | 3 | Access Apple iCloud via go, go port of pyicloud. 4 | 5 | ## Download iCloud Photos 6 | 7 | ### By Docker 8 | 9 | ```shell 10 | docker run \ 11 | -e ICLOUD_USERNAME=your_icloud_username \ 12 | -e ICLOUD_PASSWORD=your_icloud_password \ 13 | -e ICLOUD_COOKIE_DIR=/icloud_cookie \ 14 | -e ICLOUD_DOMAIN=com \ 15 | -e ICLOUD_OUTPUT=/icloud_photos \ 16 | -e ICLOUD_ALBUM= \ 17 | -e ICLOUD_THREAD_NUM=10 \ 18 | -e ICLOUD_AUTO_DELETE=true \ 19 | -e ICLOUD_STOP_FOUND_NUM=50 \ 20 | -e ICLOUD_FOLDER_STRUCTURE="2006/01/02" \ 21 | -e ICLOUD_FILE_STRUCTURE="id" \ 22 | -e ICLOUD_WITH_LIVE_PHOTO="true" \ 23 | -v /path/to/your/cookie:/icloud_cookie \ 24 | -v /path/to/your/photos:/icloud_photos \ 25 | ghcr.io/chyroc/icloud-photo-cli:0.21.1 download 26 | ``` 27 | 28 | ### By Go 29 | 30 | - **Install** 31 | 32 | ```shell 33 | go install github.com/chyroc/icloudgo/icloud-photo-cli@latest 34 | ``` 35 | 36 | - **Usage** 37 | 38 | ```shell 39 | NAME: 40 | icloud-photo-cli download 41 | 42 | USAGE: 43 | icloud-photo-cli download [command options] [arguments...] 44 | 45 | DESCRIPTION: 46 | download photos 47 | 48 | OPTIONS: 49 | --username value, -u value apple id username [$ICLOUD_USERNAME] 50 | --password value, -p value apple id password [$ICLOUD_PASSWORD] 51 | --cookie-dir value, -c value cookie dir [$ICLOUD_COOKIE_DIR] 52 | --domain value, -d value icloud domain(com,cn) (default: com) [$ICLOUD_DOMAIN] 53 | --output value, -o value output dir (default: "./iCloudPhotos") [$ICLOUD_OUTPUT] 54 | --album value, -a value album name, if not set, download all albums [$ICLOUD_ALBUM] 55 | --folder-structure 2006, --fs 2006 folder structure, support: 2006(year), `01`(month), `02`(day), `15`(24-hour), `03`(12-hour), `04`(minute), `05`(second), example: `2006/01/02`, default is `/` [$ICLOUD_FOLDER_STRUCTURE] 56 | --file-structure value support: id(unique file id), name(file human readable name) (default: "id") [$ICLOUD_FILE_STRUCTURE] 57 | --stop-found-num stop-found-num, -s stop-found-num stop download when found stop-found-num photos have been downloaded (default: 0) [$ICLOUD_STOP_FOUND_NUM] 58 | --thread-num value, -t value thread num, if not set, means 1 (default: 1) [$ICLOUD_THREAD_NUM] 59 | --auto-delete, --ad Automatically delete photos from local but recently deleted folders (default: true) [$ICLOUD_AUTO_DELETE] 60 | --with-live-photo, --lp Save video of the live photo (default: true) [$ICLOUD_WITH_LIVE_PHOTO] 61 | --help, -h show help 62 | ``` 63 | 64 | 65 | ## Upload iCloud Photos 66 | 67 | ### By Docker 68 | 69 | ```shell 70 | docker run \ 71 | -i \ 72 | -e ICLOUD_USERNAME=your_icloud_username \ 73 | -e ICLOUD_PASSWORD=your_icloud_password \ 74 | -e ICLOUD_COOKIE_DIR=/icloud_cookie \ 75 | -e ICLOUD_DOMAIN=com \ 76 | -e ICLOUD_FILE=/icloud_photos/filepath \ 77 | -v /path/to/your/cookie:/icloud_cookie \ 78 | -v /path/to/your/photos:/icloud_photos \ 79 | ghcr.io/chyroc/icloud-photo-cli:0.21.1 upload 80 | ``` 81 | 82 | ### By Go 83 | 84 | - **Install** 85 | 86 | ```shell 87 | go install github.com/chyroc/icloudgo/icloud-photo-cli@latest 88 | ``` 89 | 90 | - **Usage** 91 | 92 | ```shell 93 | NAME: 94 | icloud-photo-cli upload 95 | 96 | USAGE: 97 | icloud-photo-cli upload [command options] [arguments...] 98 | 99 | DESCRIPTION: 100 | upload photos 101 | 102 | OPTIONS: 103 | --username value, -u value apple id username [$ICLOUD_USERNAME] 104 | --password value, -p value apple id password [$ICLOUD_PASSWORD] 105 | --cookie-dir value, -c value cookie dir [$ICLOUD_COOKIE_DIR] 106 | --domain value, -d value icloud domain(com,cn) (default: com) [$ICLOUD_DOMAIN] 107 | --file value, -f value file path [$ICLOUD_FILE] 108 | --help, -h show help 109 | ``` 110 | -------------------------------------------------------------------------------- /assets/icloud_photo_sync.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chyroc/icloudgo/76d944d775a5460fd8d76370c4a52a9547ade4bd/assets/icloud_photo_sync.jpg -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | app: 5 | image: ghcr.io/chyroc/icloud-photo-cli:0.21.1 6 | environment: 7 | - ICLOUD_USERNAME=your_icloud_username 8 | - ICLOUD_PASSWORD=your_icloud_password 9 | - ICLOUD_COOKIE_DIR=/icloud_cookie 10 | - ICLOUD_DOMAIN=cn 11 | - ICLOUD_OUTPUT=/icloud_photos 12 | - ICLOUD_ALBUM= 13 | - ICLOUD_RECENT=0 14 | - ICLOUD_THREAD_NUM=10 15 | - ICLOUD_AUTO_DELETE=true 16 | - ICLOUD_STOP_FOUND_NUM=50 17 | - ICLOUD_WITH_LIVE_PHOTO=true 18 | volumes: 19 | - /path/your_icloud/cookie:/icloud_cookie 20 | - /path/your_icloud/photos:/icloud_photos 21 | command: 22 | - download -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chyroc/icloudgo 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/chyroc/gorequests v0.33.0 7 | github.com/dgraph-io/badger/v3 v3.2103.5 8 | github.com/satori/go.uuid v1.2.0 9 | github.com/urfave/cli/v2 v2.27.0 10 | ) 11 | 12 | require ( 13 | github.com/OneOfOne/xxhash v1.2.8 // indirect 14 | github.com/cespare/xxhash v1.1.0 // indirect 15 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 16 | github.com/chyroc/persistent-cookiejar v0.1.0 // indirect 17 | github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect 18 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 19 | github.com/dgraph-io/ristretto v0.1.1 // indirect 20 | github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect 21 | github.com/dustin/go-humanize v1.0.1 // indirect 22 | github.com/frankban/quicktest v1.14.6 // indirect 23 | github.com/gogo/protobuf v1.3.2 // indirect 24 | github.com/golang/glog v1.2.0 // indirect 25 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 26 | github.com/golang/protobuf v1.5.3 // indirect 27 | github.com/golang/snappy v0.0.4 // indirect 28 | github.com/google/flatbuffers v23.5.26+incompatible // indirect 29 | github.com/klauspost/compress v1.17.4 // indirect 30 | github.com/pkg/errors v0.9.1 // indirect 31 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 32 | github.com/rogpeppe/go-internal v1.12.0 // indirect 33 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 34 | github.com/stretchr/testify v1.8.4 // indirect 35 | github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect 36 | go.opencensus.io v0.24.0 // indirect 37 | golang.org/x/net v0.19.0 // indirect 38 | golang.org/x/sys v0.15.0 // indirect 39 | google.golang.org/protobuf v1.32.0 // indirect 40 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 41 | ) 42 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 4 | github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= 5 | github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= 6 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 7 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 8 | github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= 9 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 10 | github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 11 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 12 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 13 | github.com/chyroc/gorequests v0.33.0 h1:04ydHCOaLYt/JWusaZoKR4x/jtGRJqKN1z+ZjW6vONA= 14 | github.com/chyroc/gorequests v0.33.0/go.mod h1:CZDj+0SZvzDtZsFKnX971eHls2bwUtGXTcmQ6by9ONA= 15 | github.com/chyroc/persistent-cookiejar v0.1.0 h1:F7rGmT5sShfskgbZmN9MOUJS8CwcSsm8KbErcAPUO5s= 16 | github.com/chyroc/persistent-cookiejar v0.1.0/go.mod h1:eb/Xy6R1GfUrLpPD8AdIxnZ0dbihI6yDITF3btgmnJU= 17 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 18 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 19 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 20 | github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= 21 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 22 | github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= 23 | github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= 24 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 25 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 26 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 29 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 30 | github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg= 31 | github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw= 32 | github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= 33 | github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= 34 | github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= 35 | github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= 36 | github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= 37 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 38 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 39 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 40 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 41 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 42 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 43 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 44 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 45 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 46 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 47 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 48 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 49 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 50 | github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68= 51 | github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= 52 | github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 53 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 54 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 55 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 56 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 57 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 58 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 59 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 60 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 61 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 62 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 63 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 64 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 65 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= 66 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 67 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 68 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 69 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 70 | github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 71 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 72 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 73 | github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= 74 | github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= 75 | github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= 76 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 77 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 78 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 79 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 80 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 81 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 82 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 83 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 84 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 85 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 86 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 87 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 88 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 89 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 90 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 91 | github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= 92 | github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= 93 | github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= 94 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 95 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 96 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 97 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 98 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 99 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 100 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 101 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 102 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 103 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 104 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 105 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 106 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 107 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 108 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 109 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 110 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 111 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 112 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 113 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 114 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 115 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 116 | github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= 117 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 118 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 119 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= 120 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 121 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 122 | github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 123 | github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 124 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 125 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 126 | github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= 127 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 128 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 129 | github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= 130 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 131 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 132 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 133 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 134 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 135 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 136 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 137 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 138 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 139 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 140 | github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= 141 | github.com/urfave/cli/v2 v2.27.0 h1:uNs1K8JwTFL84X68j5Fjny6hfANh9nTlJ6dRtZAFAHY= 142 | github.com/urfave/cli/v2 v2.27.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= 143 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 144 | github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI= 145 | github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 146 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 147 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 148 | go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= 149 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= 150 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 151 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 152 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 153 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 154 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 155 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 156 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 157 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 158 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 159 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 160 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 161 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 162 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 163 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 164 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 165 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 166 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 167 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 168 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 169 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 170 | golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= 171 | golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= 172 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 173 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 174 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 175 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 176 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 177 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 178 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 179 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 180 | golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 181 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 182 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 183 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 184 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 185 | golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 186 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 187 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 188 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 189 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 190 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 191 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 192 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 193 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 194 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 195 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 196 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 197 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 198 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 199 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 200 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 201 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 202 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 203 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 204 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 205 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 206 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 207 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= 208 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 209 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= 210 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 211 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 212 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 213 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= 214 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 215 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 216 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 217 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 218 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 219 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 220 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 221 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 222 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 223 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 224 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 225 | google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= 226 | google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 227 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 228 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 229 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 230 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 231 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 232 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 233 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 234 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 235 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 236 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 237 | -------------------------------------------------------------------------------- /icloud-photo-admin/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.env.local 3 | /.umirc.local.ts 4 | /config/config.local.ts 5 | /src/.umi 6 | /src/.umi-production 7 | /src/.umi-test 8 | /dist 9 | .swc 10 | -------------------------------------------------------------------------------- /icloud-photo-admin/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.com/ 2 | public-hoist-pattern[]=*@nextui-org/* -------------------------------------------------------------------------------- /icloud-photo-admin/.umirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "umi"; 2 | 3 | export default defineConfig({ 4 | routes: [ 5 | {path: "/addAccount", component: "addAccount"}, 6 | {path: "/login", component: "login"}, 7 | {path: "/register", component: "register"}, 8 | {path: "/accountManage", component: "accountManage"}, 9 | {path: "/configure/:accountEmail", component: "configure"}, 10 | ], 11 | npmClient: 'pnpm', 12 | esbuildMinifyIIFE: true 13 | }); 14 | -------------------------------------------------------------------------------- /icloud-photo-admin/mock/api.ts: -------------------------------------------------------------------------------- 1 | import { defineMock } from "umi"; 2 | 3 | export default defineMock({ 4 | 'POST /api/login': (req, res) => { 5 | res.send({ 6 | msg: 'success', 7 | success: true 8 | }) 9 | }, 10 | 'POST /api/register': (req, res) => { 11 | res.send({ 12 | msg: 'success', 13 | success: true, 14 | duplicate: false, 15 | }) 16 | }, 17 | 'POST /api/addAccount': (req, res) => { 18 | if (req.body.twoFactorCode === '') { 19 | res.send({ 20 | msg: 'need needsTwoFactor', 21 | needsTwoFactor: true 22 | }) 23 | return 24 | } 25 | 26 | res.send({ 27 | msg: 'success', 28 | success: true 29 | }) 30 | }, 31 | 'POST /api/delAccount': (req, res) => { 32 | res.send({ 33 | msg: 'success', 34 | success: true 35 | }) 36 | } 37 | }); -------------------------------------------------------------------------------- /icloud-photo-admin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "author": "chyroc ", 4 | "scripts": { 5 | "dev": "umi dev", 6 | "build": "umi build", 7 | "postinstall": "umi setup", 8 | "setup": "umi setup", 9 | "start": "npm run dev" 10 | }, 11 | "dependencies": { 12 | "@nextui-org/react": "^2.2.9", 13 | "autoprefixer": "^10.4.16", 14 | "axios": "^1.6.2", 15 | "framer-motion": "^10.16.16", 16 | "postcss": "^8.4.32", 17 | "tailwindcss": "^3.3.6", 18 | "umi": "^4.0.89" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^18.2.45", 22 | "@types/react-dom": "^18.2.18", 23 | "typescript": "^5.3.3" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /icloud-photo-admin/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /icloud-photo-admin/src/assets/icloud.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chyroc/icloudgo/76d944d775a5460fd8d76370c4a52a9547ade4bd/icloud-photo-admin/src/assets/icloud.jpg -------------------------------------------------------------------------------- /icloud-photo-admin/src/global.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | -------------------------------------------------------------------------------- /icloud-photo-admin/src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | export interface PageText { 2 | cn: { 3 | [key: string]: string; 4 | }, 5 | en: { 6 | [key: string]: string; 7 | } 8 | } 9 | 10 | const textContent: PageText = { 11 | en: { 12 | // login 13 | loginTitle: 'Login to Admin Account', 14 | loginAccountPlaceholder: 'Enter Admin Account', 15 | loginPasswordPlaceholder: 'Enter Admin Password', 16 | loginButton: 'Login', 17 | loginingButton: 'Login...', 18 | 19 | // register 20 | registerTitle: 'Register Admin Account', 21 | registerButton: 'Register', 22 | registeringButton: 'Register...', 23 | 24 | // addAccount 25 | 26 | // manageAccount 27 | email: 'Email', 28 | totalNumber: 'Total Number', 29 | downloadedNumber: 'Downloaded Number', 30 | lastSync: 'Last Sync', 31 | configure: 'Configure', 32 | delete: 'Delete', 33 | 34 | // view 35 | accountManager: 'iCloud Account Manager', 36 | deleteAccount: 'Delete Account', 37 | confirmDeleteAccount: 'Are you sure to delete', 38 | areYes: '?', 39 | toggleLanguage: 'Switch Language', 40 | addAccount: 'Add iCloud Account', 41 | accountPlaceholder: 'Enter iCloud Account', 42 | passwordPlaceholder: 'Enter iCloud Password', 43 | twoFACodePlaceholder: 'Enter 2FA Code', 44 | addButton: 'Add Account', 45 | addingButton: 'Adding...', 46 | 47 | // config 48 | icloudPhotoDownloadConfig: 'iCloud Photo Downloader Config', 49 | iCloudAccount: 'iCloud Account', 50 | iCloudPassword: 'iCloud Password', 51 | folderStruct: 'Folder Structure', 52 | threadNum: 'Thread Num', 53 | save: 'Save', 54 | cancel: 'Cancel', 55 | }, 56 | cn: { 57 | // login 58 | loginTitle: '登录管理账号', 59 | loginAccountPlaceholder: '请输入管理账号', 60 | loginPasswordPlaceholder: '请输入管理密码', 61 | loginButton: '登录', 62 | loginingButton: '登录...', 63 | 64 | // register 65 | registerTitle: '注册管理账号', 66 | registerButton: '注册', 67 | registeringButton: '注册...', 68 | 69 | accountManager: 'iCloud 账号管理', 70 | deleteAccount: '删除账号', 71 | confirmDeleteAccount: '确定要删除账号', 72 | areYes: '吗?', 73 | toggleLanguage: '切换语言', 74 | addAccount: '添加 iCloud 账号', 75 | accountPlaceholder: '请输入 iCloud 账号', 76 | passwordPlaceholder: '请输入 iCloud 密码', 77 | twoFACodePlaceholder: '请输入 2FA 代码', 78 | addButton: '添加账号', 79 | addingButton: '正在添加...', 80 | email: '邮箱', 81 | totalNumber: '总照片数', 82 | downloadedNumber: '已下载照片数', 83 | lastSync: '上次同步时间', 84 | configure: '配置', 85 | delete: '删除', 86 | 87 | // config 88 | icloudPhotoDownloadConfig: 'iCloud 照片下载器配置', 89 | iCloudAccount: 'iCloud 账号', 90 | iCloudPassword: 'iCloud 密码', 91 | folderStruct: '文件夹格式', 92 | threadNum: '并发数', 93 | save: '保存', 94 | cancel: '取消', 95 | } 96 | }; 97 | 98 | export default textContent -------------------------------------------------------------------------------- /icloud-photo-admin/src/layouts/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Outlet } from 'umi'; 2 | import {NextUIProvider} from "@nextui-org/react"; 3 | 4 | export default function Layout() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /icloud-photo-admin/src/net/index.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | 4 | export const login = async (account: string, password: string) => { 5 | const response = await axios.post( 6 | '/api/login', { 7 | account, 8 | password, 9 | }); 10 | const {success} = response.data; 11 | return {success} 12 | } 13 | 14 | export const register = async (account: string, password: string) => { 15 | const response = await axios.post( 16 | '/api/register', { 17 | account, 18 | password, 19 | }); 20 | const {success} = response.data; 21 | return {success} 22 | } 23 | 24 | 25 | export const addAccount = async (account: string, password: string, twoFactorCode: string) => { 26 | const response = await axios.post( 27 | '/api/addAccount', { 28 | account, 29 | password, 30 | twoFactorCode, 31 | }); 32 | const {needsTwoFactor, success} = response.data; 33 | return {needsTwoFactor, success} 34 | } 35 | 36 | export const delAccount = async (account: string) => { 37 | const response = await axios.post( 38 | '/api/delAccount', { 39 | account, 40 | }); 41 | const {success} = response.data; 42 | return {success} 43 | } -------------------------------------------------------------------------------- /icloud-photo-admin/src/pages/accountManage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@nextui-org/react'; 3 | import textContent from "@/i18n"; 4 | import { history } from 'umi' 5 | import { delAccount } from "@/net"; 6 | 7 | export interface Account { 8 | id: string; 9 | email: string; 10 | totalPhotos: number; 11 | downloadedPhotos: number; 12 | lastSync: string; 13 | } 14 | 15 | 16 | const accountsData: Account[] = [ 17 | // 示例数据,您需要根据实际情况调整 18 | {id: '1', email: 'example1@icloud.com', totalPhotos: 1000, downloadedPhotos: 800, lastSync: '2021-01-01'}, 19 | {id: '2', email: 'example2@icloud.com', totalPhotos: 300, downloadedPhotos: 40, lastSync: '2022-01-01'}, 20 | // ... 其他账号数据 21 | ]; 22 | 23 | const IndexPage = () => { 24 | // ... 省略的状态和函数 25 | const [deleteModal, setDeleteModal] = useState(false) 26 | const [selectedAccount, setSelectedAccount] = useState(null); 27 | const [language, setLanguage] = useState('cn'); 28 | const [accountList, setAccountList] = useState(accountsData) 29 | 30 | const handleDeleteClick = async (account: Account) => { 31 | setSelectedAccount(account); 32 | setDeleteModal(true) 33 | }; 34 | 35 | const handleDeleteModalChange = async (del: boolean, email: string) => { 36 | if (del) { 37 | const {success} = await delAccount(email) 38 | if (success) { 39 | setAccountList(accountList.filter(v => v.email != email)) 40 | } 41 | } 42 | setDeleteModal(false) 43 | console.log(del, email) 44 | } 45 | 46 | const handleConfigClick = (account: Account) => { 47 | history.push(`/configure/${account.email}`) 48 | } 49 | 50 | const handleAddAccount = () => { 51 | history.push(`/addAccount`) 52 | } 53 | 54 | const toggleLanguage = () => { 55 | setLanguage(language === 'cn' ? 'en' : 'cn'); 56 | }; 57 | 58 | const texts = language === 'cn' ? textContent.cn : textContent.en; 59 | 60 | 61 | return ( 62 |
63 |
64 |

{texts.accountManager}

65 | 66 | 69 | 70 | {accountList.map((account) => ( 71 |
73 |
{texts.email}: {account.email}
74 |
{texts.totalNumber}: {account.totalPhotos}
75 |
{texts.downloadedNumber}: {account.downloadedPhotos}
76 |
{texts.lastSync}: {account.lastSync}
77 |
78 | 79 | 80 |
81 |
82 | ))} 83 |
84 | 85 |
86 | 87 | {language === 'cn' ? '🇨🇳' : '🇺🇸'} {texts.toggleLanguage} 88 | 89 |
90 | 91 | { 92 | !!selectedAccount && 93 | 94 | 95 | {(onClose) => ( 96 | <> 97 | {texts.deleteAccount} 98 | 99 |

100 | {texts.confirmDeleteAccount} {selectedAccount.email} {texts.areYes} 101 |

102 |
103 | 104 | 108 | 111 | 112 | 113 | )} 114 |
115 |
116 | } 117 |
118 | ); 119 | }; 120 | 121 | export default IndexPage; 122 | -------------------------------------------------------------------------------- /icloud-photo-admin/src/pages/addAccount.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Button, Input, Spacer } from '@nextui-org/react'; 3 | import textContent from "@/i18n"; 4 | import iCloudLogo from '@/assets/icloud.jpg' 5 | import { history } from 'umi'; 6 | import { addAccount } from "@/net"; 7 | 8 | const IndexPage = () => { 9 | const [account, setAccount] = useState(''); 10 | const [password, setPassword] = useState(''); 11 | const [twoFactorCode, setTwoFactorCode] = useState(''); 12 | const [needsTwoFactor, setNeedsTwoFactor] = useState(false); 13 | const [isLoading, setIsLoading] = useState(false); 14 | const [language, setLanguage] = useState('cn'); 15 | 16 | const texts = language === 'cn' ? textContent.cn : textContent.en; 17 | 18 | const handleSubmit = async () => { 19 | setIsLoading(true); 20 | try { 21 | const {needsTwoFactor, success} = await addAccount(account, password, twoFactorCode) 22 | if (needsTwoFactor) { 23 | setNeedsTwoFactor(true); 24 | } else if (success) { 25 | // 处理账号添加成功 26 | history.push('/accountManage') 27 | } 28 | } catch (error) { 29 | // 处理错误 30 | } 31 | setIsLoading(false); 32 | }; 33 | 34 | const toggleLanguage = () => { 35 | setLanguage(language === 'cn' ? 'en' : 'cn'); 36 | }; 37 | 38 | 39 | return ( 40 |
41 |
42 |

{texts.addAccount}

43 | 44 |
45 | setAccount(e.target.value)}/> 50 | 51 | setPassword(e.target.value)}/> 57 | {needsTwoFactor && ( 58 | <> 59 | 60 | setTwoFactorCode(e.target.value)}/> 65 | 66 | )} 67 | 68 | 71 |
72 |
73 |
74 | 75 | {language === 'cn' ? '🇨🇳' : '🇺🇸'} {texts.toggleLanguage} 76 | 77 |
78 |
79 | ); 80 | }; 81 | 82 | export default IndexPage; 83 | -------------------------------------------------------------------------------- /icloud-photo-admin/src/pages/configure.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Button, Input, Spacer } from '@nextui-org/react'; 3 | import axios from 'axios'; 4 | import textContent from "@/i18n"; 5 | import { useLocation } from 'umi'; 6 | 7 | export default () => { 8 | const location = useLocation(); 9 | const emailAccount = location.pathname.split('configure/')[1] 10 | console.log('emailAccount', emailAccount) 11 | const [account, setAccount] = useState('x@xx.com'); 12 | const [password, setPassword] = useState(''); 13 | const [folderFormat, setFolderFormat] = useState('2006/01/02'); 14 | const [removeDeleted, setRemoveDeleted] = useState('是'); // 修改为字符串,默认为“否” 15 | const [concurrency, setConcurrency] = useState(10); 16 | const [language, setLanguage] = useState('cn'); 17 | 18 | const texts = language === 'cn' ? textContent.cn : textContent.en; 19 | 20 | const handleSubmit = async () => { 21 | // 这里发送配置信息的 HTTP 请求 22 | try { 23 | await axios.post('/api/config', {account, password, folderFormat, removeDeleted, concurrency}); 24 | // 处理成功响应 25 | } catch (error) { 26 | // 处理错误 27 | } 28 | }; 29 | 30 | const handleCancel = () => { 31 | // 返回上一页 32 | window.history.back(); 33 | }; 34 | 35 | const toggleLanguage = () => { 36 | setLanguage(language === 'cn' ? 'en' : 'cn'); 37 | }; 38 | 39 | return ( 40 |
41 |
42 |

{texts.icloudPhotoDownloadConfig}

43 | setAccount(e.target.value)} 49 | /> 50 | 51 | setPassword(e.target.value)} 57 | /> 58 | 59 | setFolderFormat(e.target.value)} 64 | /> 65 | 66 | setConcurrency(parseInt(e.target.value, 10))} 72 | /> 73 | 74 |
75 | 76 | 77 |
78 |
79 |
80 | 81 | {language === 'cn' ? '🇨🇳' : '🇺🇸'} {texts.toggleLanguage} 82 | 83 |
84 |
85 | ); 86 | }; 87 | 88 | -------------------------------------------------------------------------------- /icloud-photo-admin/src/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Button, Input, Spacer } from '@nextui-org/react'; 3 | import textContent from "@/i18n"; 4 | import { history } from 'umi'; 5 | import { login } from "@/net"; 6 | 7 | const IndexPage = () => { 8 | const [account, setAccount] = useState(''); 9 | const [password, setPassword] = useState(''); 10 | const [isLoading, setIsLoading] = useState(false); 11 | const [language, setLanguage] = useState('cn'); 12 | 13 | const texts = language === 'cn' ? textContent.cn : textContent.en; 14 | 15 | const handleSubmit = async () => { 16 | setIsLoading(true); 17 | try { 18 | const {success} = await login(account, password) 19 | if (success) { 20 | // 处理账号添加成功 21 | history.push('/accountManager') 22 | } 23 | } catch (error) { 24 | // 处理错误 25 | } 26 | setIsLoading(false); 27 | }; 28 | 29 | const toggleLanguage = () => { 30 | setLanguage(language === 'cn' ? 'en' : 'cn'); 31 | }; 32 | 33 | 34 | return ( 35 |
36 |
37 |

{texts.loginTitle}

38 |
39 | setAccount(e.target.value)}/> 44 | 45 | setPassword(e.target.value)}/> 51 | 52 | 55 |
56 |
57 |
58 | 59 | {language === 'cn' ? '🇨🇳' : '🇺🇸'} {texts.toggleLanguage} 60 | 61 |
62 |
63 | ); 64 | }; 65 | 66 | export default IndexPage; 67 | -------------------------------------------------------------------------------- /icloud-photo-admin/src/pages/register.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Button, Input, Spacer } from '@nextui-org/react'; 3 | import textContent from "@/i18n"; 4 | import { history } from 'umi'; 5 | import { login, register } from "@/net"; 6 | 7 | const IndexPage = () => { 8 | const [account, setAccount] = useState(''); 9 | const [password, setPassword] = useState(''); 10 | const [isLoading, setIsLoading] = useState(false); 11 | const [language, setLanguage] = useState('cn'); 12 | 13 | const texts = language === 'cn' ? textContent.cn : textContent.en; 14 | 15 | const handleSubmit = async () => { 16 | setIsLoading(true); 17 | try { 18 | const {success} = await register(account, password) 19 | if (success) { 20 | // 处理账号添加成功 21 | history.push('/login') 22 | } 23 | } catch (error) { 24 | // 处理错误 25 | } 26 | setIsLoading(false); 27 | }; 28 | 29 | const toggleLanguage = () => { 30 | setLanguage(language === 'cn' ? 'en' : 'cn'); 31 | }; 32 | 33 | 34 | return ( 35 |
36 |
37 |

{texts.registerTitle}

38 |
39 | setAccount(e.target.value)}/> 44 | 45 | setPassword(e.target.value)}/> 51 | 52 | 55 |
56 |
57 |
58 | 59 | {language === 'cn' ? '🇨🇳' : '🇺🇸'} {texts.toggleLanguage} 60 | 61 |
62 |
63 | ); 64 | }; 65 | 66 | export default IndexPage; 67 | -------------------------------------------------------------------------------- /icloud-photo-admin/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | // tailwind.config.js 3 | const {nextui} = require("@nextui-org/react"); 4 | 5 | /** @type {import('tailwindcss').Config} */ 6 | module.exports = { 7 | content: [ 8 | // ... 9 | "./src/**/*.{html,js,jsx,tsx}", 10 | "./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}", 11 | ], 12 | theme: { 13 | extend: {}, 14 | }, 15 | darkMode: "class", 16 | plugins: [nextui()], 17 | }; -------------------------------------------------------------------------------- /icloud-photo-admin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./src/.umi/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /icloud-photo-admin/typings.d.ts: -------------------------------------------------------------------------------- 1 | import 'umi/typings'; 2 | -------------------------------------------------------------------------------- /icloud-photo-cli/command/command_download.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "sort" 9 | "strings" 10 | "sync" 11 | "sync/atomic" 12 | "time" 13 | 14 | "github.com/chyroc/icloudgo" 15 | "github.com/chyroc/icloudgo/internal" 16 | "github.com/dgraph-io/badger/v3" 17 | "github.com/urfave/cli/v2" 18 | ) 19 | 20 | func NewDownloadFlag() []cli.Flag { 21 | var res []cli.Flag 22 | res = append(res, commonFlag...) 23 | res = append(res, 24 | &cli.StringFlag{ 25 | Name: "output", 26 | Usage: "output dir", 27 | Required: false, 28 | Value: "./iCloudPhotos", 29 | Aliases: []string{"o"}, 30 | EnvVars: []string{"ICLOUD_OUTPUT"}, 31 | }, 32 | &cli.StringFlag{ 33 | Name: "album", 34 | Usage: "album name, if not set, download all albums", 35 | Required: false, 36 | Aliases: []string{"a"}, 37 | EnvVars: []string{"ICLOUD_ALBUM"}, 38 | }, 39 | &cli.StringFlag{ 40 | Name: "folder-structure", 41 | Usage: "support: `2006`(year), `01`(month), `02`(day), `15`(24-hour), `03`(12-hour), `04`(minute), `05`(second), example: `2006/01/02`, default is `/`", 42 | Required: false, 43 | Value: "/", 44 | Aliases: []string{"fs"}, 45 | EnvVars: []string{"ICLOUD_FOLDER_STRUCTURE"}, 46 | }, 47 | &cli.StringFlag{ 48 | Name: "file-structure", 49 | Usage: "support: id(unique file id), name(file human readable name)", 50 | Required: false, 51 | Value: "id", 52 | EnvVars: []string{"ICLOUD_FILE_STRUCTURE"}, 53 | }, 54 | &cli.IntFlag{ 55 | Name: "stop-found-num", 56 | Usage: "stop download when found `stop-found-num` photos have been downloaded", 57 | Required: false, 58 | Value: 0, 59 | Aliases: []string{"s"}, 60 | EnvVars: []string{"ICLOUD_STOP_FOUND_NUM"}, 61 | }, 62 | &cli.IntFlag{ 63 | Name: "thread-num", 64 | Usage: "thread num, if not set, means 1", 65 | Required: false, 66 | Aliases: []string{"t"}, 67 | Value: 1, 68 | EnvVars: []string{"ICLOUD_THREAD_NUM"}, 69 | }, 70 | &cli.BoolFlag{ 71 | Name: "auto-delete", 72 | Usage: "Automatically delete photos from local but recently deleted folders", 73 | Required: false, 74 | Value: true, 75 | Aliases: []string{"ad"}, 76 | EnvVars: []string{"ICLOUD_AUTO_DELETE"}, 77 | }, 78 | &cli.BoolFlag{ 79 | Name: "with-live-photo", 80 | Usage: "Save video of the live photo", 81 | Required: false, 82 | Value: true, 83 | Aliases: []string{"lp"}, 84 | EnvVars: []string{"ICLOUD_WITH_LIVE_PHOTO"}, 85 | }, 86 | ) 87 | return res 88 | } 89 | 90 | func Download(c *cli.Context) error { 91 | cmd, err := newDownloadCommand(c) 92 | if err != nil { 93 | return err 94 | } 95 | defer cmd.client.Close() 96 | 97 | go cmd.saveMeta() //nolint:errcheck 98 | go cmd.download() //nolint:errcheck 99 | go cmd.autoDeletePhoto() //nolint:errcheck 100 | 101 | // hold 102 | <-cmd.exit 103 | 104 | cmd.Close() 105 | 106 | return nil 107 | } 108 | 109 | type downloadCommand struct { 110 | Username string 111 | Password string 112 | CookieDir string 113 | Domain string 114 | Output string 115 | StopNum int 116 | AlbumName string 117 | ThreadNum int 118 | AutoDelete bool 119 | WithLivePhoto bool 120 | FolderStructure string 121 | FileStructure string 122 | 123 | client *icloudgo.Client 124 | photoCli *icloudgo.PhotoService 125 | db *badger.DB 126 | lock *sync.Mutex 127 | exit chan struct{} 128 | startDownload chan struct{} 129 | } 130 | 131 | func newDownloadCommand(c *cli.Context) (*downloadCommand, error) { 132 | cmd := &downloadCommand{ 133 | Username: c.String("username"), 134 | Password: c.String("password"), 135 | CookieDir: c.String("cookie-dir"), 136 | Domain: c.String("domain"), 137 | Output: c.String("output"), 138 | StopNum: c.Int("stop-found-num"), 139 | AlbumName: c.String("album"), 140 | ThreadNum: c.Int("thread-num"), 141 | WithLivePhoto: c.Bool("with-live-photo"), 142 | AutoDelete: c.Bool("auto-delete"), 143 | FolderStructure: c.String("folder-structure"), 144 | FileStructure: c.String("file-structure"), 145 | lock: &sync.Mutex{}, 146 | exit: make(chan struct{}), 147 | startDownload: make(chan struct{}), 148 | } 149 | if cmd.AlbumName == "" { 150 | cmd.AlbumName = icloudgo.AlbumNameAll 151 | } 152 | 153 | cli, err := icloudgo.New(&icloudgo.ClientOption{ 154 | AppID: cmd.Username, 155 | CookieDir: cmd.CookieDir, 156 | TwoFACodeGetter: &internal.StdinTextGetter{Tip: "2fa code"}, 157 | Domain: cmd.Domain, 158 | }) 159 | if err != nil { 160 | return nil, err 161 | } 162 | if err := cli.Authenticate(false, nil); err != nil { 163 | return nil, err 164 | } 165 | photoCli, err := cli.PhotoCli() 166 | if err != nil { 167 | return nil, err 168 | } 169 | 170 | dbPath := cli.ConfigPath("badger.db") 171 | db, err := badger.Open(badger.DefaultOptions(dbPath)) 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | cmd.client = cli 177 | cmd.photoCli = photoCli 178 | cmd.db = db 179 | 180 | return cmd, nil 181 | } 182 | 183 | func (r *downloadCommand) saveMeta() (err error) { 184 | defer func() { 185 | if err != nil { 186 | fmt.Printf("[icloudgo] [meta] final err:%s\n", err.Error()) 187 | } 188 | }() 189 | album, err := r.photoCli.GetAlbum(r.AlbumName) 190 | if err != nil { 191 | return err 192 | } 193 | 194 | for { 195 | dbOffset := r.dalGetDownloadOffset(album.Size()) 196 | fmt.Printf("[icloudgo] [meta] album: %s, total: %d, db_offset: %d, target: %s, thread-num: %d, stop-num: %d\n", album.Name, album.Size(), dbOffset, r.Output, r.ThreadNum, r.StopNum) 197 | err = album.WalkPhotos(dbOffset, func(offset int64, assets []*internal.PhotoAsset) error { 198 | if err := r.dalAddAssets(assets); err != nil { 199 | return err 200 | } 201 | if err := r.saveDownloadOffset(nil, offset, true); err != nil { 202 | return err 203 | } 204 | fmt.Printf("[icloudgo] [meta] update download offst to %d\n", offset) 205 | r.setStartDownload() 206 | return nil 207 | }) 208 | if err != nil { 209 | fmt.Printf("[icloudgo] [meta] walk photos err: %s\n", err) 210 | time.Sleep(time.Minute) 211 | } else { 212 | time.Sleep(time.Hour) 213 | } 214 | } 215 | } 216 | 217 | func (r *downloadCommand) setStartDownload() { 218 | select { 219 | case r.startDownload <- struct{}{}: 220 | return 221 | case <-time.After(time.Second / 10): 222 | return 223 | } 224 | } 225 | 226 | func (r *downloadCommand) download() (err error) { 227 | defer func() { 228 | if err != nil { 229 | fmt.Printf("[icloudgo] [download] final err:%s\n", err.Error()) 230 | } 231 | }() 232 | if err := mkdirAll(r.Output); err != nil { 233 | return err 234 | } 235 | if err := mkdirAll(filepath.Join(r.Output, ".tmp")); err != nil { 236 | return err 237 | } 238 | 239 | fmt.Printf("[icloudgo] [download] start\n") 240 | short := time.Minute 241 | long := time.Hour 242 | timer := time.NewTimer(time.Second / 10) // 立刻开始 243 | download := func() { 244 | fmt.Printf("[icloudgo] [download] start run %s\n", time.Now()) 245 | if err := r.downloadFromDatabase(); err != nil { 246 | fmt.Printf("[icloudgo] [download] download err: %s, sleep %s", err, short) 247 | timer.Reset(short) 248 | } else { 249 | fmt.Printf("[icloudgo] [download] download success, sleep %s", long) 250 | timer.Reset(long) 251 | } 252 | } 253 | for { 254 | select { 255 | case <-r.startDownload: 256 | download() 257 | case <-timer.C: 258 | download() 259 | } 260 | } 261 | } 262 | 263 | func (r *downloadCommand) downloadFromDatabase() error { 264 | assetQueue, err := r.getUnDownloadAssets() 265 | if err != nil { 266 | return fmt.Errorf("get undownload assets err: %w", err) 267 | } else if assetQueue.empty() { 268 | fmt.Printf("[icloudgo] [download] no undownload assets\n") 269 | return nil 270 | } 271 | fmt.Printf("[icloudgo] [download] found %d undownload assets\n", assetQueue.len()) 272 | 273 | wait := new(sync.WaitGroup) 274 | foundDownloadedNum := int32(0) 275 | var downloaded int32 276 | var errCount int32 277 | var finalErr error 278 | addError := func(msg string, err error) { 279 | if err == nil { 280 | return 281 | } 282 | atomic.AddInt32(&errCount, 1) 283 | finalErr = err 284 | fmt.Printf("[icloudgo] [download] %s failed: %s\n", msg, err.Error()) 285 | } 286 | for threadIndex := 0; threadIndex < r.ThreadNum; threadIndex++ { 287 | wait.Add(1) 288 | go func(threadIndex int) { 289 | defer wait.Done() 290 | for { 291 | if atomic.LoadInt32(&errCount) > 20 { 292 | fmt.Printf("[icloudgo] [download] too many errors, stop download, last error: %s\n", finalErr.Error()) 293 | os.Exit(1) 294 | return 295 | } 296 | 297 | if r.StopNum > 0 && atomic.LoadInt32(&foundDownloadedNum) >= int32(r.StopNum) { 298 | return 299 | } 300 | 301 | photoAsset, pickReason := assetQueue.pick(float32(threadIndex) / float32(r.ThreadNum)) 302 | if photoAsset == nil { 303 | return 304 | } 305 | 306 | if isDownloaded, err := r.downloadPhotoAsset(photoAsset, pickReason); err != nil { 307 | if errors.Is(err, internal.ErrResourceGone) || strings.Contains(err.Error(), "no such host") { 308 | // delete db 309 | if err := r.dalDeleteAsset(photoAsset.ID()); err != nil { 310 | fmt.Printf("[icloudgo] [download] remove gone resource failed: %s\n", err) 311 | } 312 | continue 313 | } 314 | addError("downloadPhotoAsset", err) 315 | continue 316 | } else if isDownloaded { 317 | if err = r.dalSetDownloaded(photoAsset.ID()); err != nil { 318 | addError("dalSetDownloaded[downloaded]", err) 319 | continue 320 | } 321 | atomic.AddInt32(&foundDownloadedNum, 1) 322 | if r.StopNum > 0 && foundDownloadedNum >= int32(r.StopNum) { 323 | return 324 | } 325 | } else { 326 | if err = r.dalSetDownloaded(photoAsset.ID()); err != nil { 327 | addError("dalSetDownloaded[download]", err) 328 | continue 329 | } 330 | atomic.AddInt32(&downloaded, 1) 331 | } 332 | } 333 | }(threadIndex) 334 | } 335 | wait.Wait() 336 | return nil 337 | } 338 | 339 | func (r *downloadCommand) downloadPhotoAsset(photo *icloudgo.PhotoAsset, pickReason string) (bool, error) { 340 | isDownloaded, err := r.downloadPhotoAssetInternal(photo, pickReason, false) 341 | if err != nil { 342 | return false, err 343 | } 344 | if photo.IsLivePhoto() { 345 | if !r.WithLivePhoto { 346 | fmt.Printf("[icloudgo] [download] [%s] %s live photo skip\n", pickReason, photo.Filename(true)) 347 | return isDownloaded, nil 348 | } 349 | isDownloaded2, err := r.downloadPhotoAssetInternal(photo, pickReason, true) 350 | if err != nil { 351 | return false, err 352 | } 353 | return isDownloaded && isDownloaded2, nil 354 | } 355 | return isDownloaded, nil 356 | } 357 | 358 | func (r *downloadCommand) downloadPhotoAssetInternal(photo *icloudgo.PhotoAsset, pickReason string, livePhoto bool) (bool, error) { 359 | outputDir := photo.OutputDir(r.Output, r.FolderStructure) 360 | tmpPath := photo.LocalPath(filepath.Join(r.Output, ".tmp"), icloudgo.PhotoVersionOriginal, r.FileStructure, livePhoto) 361 | path := photo.LocalPath(outputDir, icloudgo.PhotoVersionOriginal, r.FileStructure, livePhoto) 362 | name := path[len(r.Output):] 363 | 364 | oldOutputDir := photo.OldOutputDir(r.Output, r.FolderStructure) 365 | oldPath := photo.LocalPath(oldOutputDir, icloudgo.PhotoVersionOriginal, r.FileStructure, livePhoto) 366 | 367 | if err := os.MkdirAll(outputDir, os.ModePerm); err != nil { 368 | fmt.Printf("[icloudgo] [download] [%s] mkdir '%s' output dir: '%s' failed: %s\n", pickReason, photo.Filename(livePhoto), outputDir, err) 369 | return false, err 370 | } 371 | 372 | // 如果 old 存在, 直接移动到新目录 373 | if oldPath != path { 374 | if f, _ := os.Stat(oldPath); f != nil { 375 | if err := os.Rename(oldPath, path); err != nil { 376 | fmt.Printf("[icloudgo] [download] [%s] compatible with wrong photo time for '%s' failed: %s\n", pickReason, name, err) 377 | return false, err 378 | } else { 379 | fmt.Printf("[icloudgo] [download] [%s] compatible with wrong photo time for '%s' success\n", pickReason, name) 380 | fmt.Printf("%s -> %s\n", oldPath, path) 381 | } 382 | } 383 | } 384 | 385 | if f, _ := os.Stat(path); f != nil { 386 | if photo.Size() != int(f.Size()) { 387 | return false, r.downloadTo(pickReason, photo, livePhoto, tmpPath, path, name) 388 | } else { 389 | // fmt.Printf("[icloudgo] [download] '%s' exist, skip.\n", path) 390 | return true, nil 391 | } 392 | } else { 393 | return false, r.downloadTo(pickReason, photo, livePhoto, tmpPath, path, name) 394 | } 395 | } 396 | 397 | func (r *downloadCommand) downloadTo(pickReason string, photo *icloudgo.PhotoAsset, livePhoto bool, tmpPath, realPath, saveName string) (err error) { 398 | start := time.Now() 399 | fmt.Printf("[icloudgo] [download] [%s] started %v, %v, %v\n", pickReason, saveName, photo.Filename(livePhoto), photo.FormatSize()) 400 | defer func() { 401 | diff := time.Since(start) 402 | speed := float64(photo.Size()) / 1024 / diff.Seconds() 403 | if err != nil && !errors.Is(err, internal.ErrResourceGone) && !strings.Contains(err.Error(), "no such host") { 404 | fmt.Printf("[icloudgo] [download] failure %v, %v, %v/%v %.2fKB/s err=%s\n", saveName, photo.Filename(livePhoto), photo.FormatSize(), diff, speed, err) 405 | } else { 406 | fmt.Printf("[icloudgo] [download] [%s] success %v, %v, %v/%v %.2fKB/s\n", pickReason, saveName, photo.Filename(livePhoto), photo.FormatSize(), diff, speed) 407 | } 408 | }() 409 | retry := 5 410 | for i := 0; i < retry; i++ { 411 | if err := photo.DownloadTo(icloudgo.PhotoVersionOriginal, livePhoto, tmpPath); err != nil { 412 | if strings.Contains(err.Error(), "i/o timeout") && i < retry-1 { 413 | continue 414 | } 415 | return err 416 | } 417 | } 418 | 419 | if err := os.Rename(tmpPath, realPath); err != nil { 420 | return fmt.Errorf("rename '%s' to '%s' failed: %w", tmpPath, realPath, err) 421 | } 422 | 423 | return nil 424 | } 425 | 426 | func (r *downloadCommand) autoDeletePhoto() (err error) { 427 | defer func() { 428 | if err != nil { 429 | fmt.Printf("[icloudgo] [auto_delete] final err:%s\n", err.Error()) 430 | } 431 | }() 432 | if !r.AutoDelete { 433 | return nil 434 | } 435 | 436 | for { 437 | album, err := r.photoCli.GetAlbum(icloudgo.AlbumNameRecentlyDeleted) 438 | if err != nil { 439 | time.Sleep(time.Minute) 440 | continue 441 | } 442 | 443 | fmt.Printf("[icloudgo] [auto_delete] auto delete album total: %d\n", album.Size()) 444 | if err = album.WalkPhotos(0, func(offset int64, assets []*internal.PhotoAsset) error { 445 | for _, photoAsset := range assets { 446 | if err := r.dalDeleteAsset(photoAsset.ID()); err != nil { 447 | return err 448 | } 449 | if err := r.removeLocalFile(photoAsset, false); err != nil { 450 | return err 451 | } 452 | if err := r.removeLocalFile(photoAsset, true); err != nil { 453 | return err 454 | } 455 | } 456 | return nil 457 | }); err != nil { 458 | time.Sleep(time.Minute) 459 | continue 460 | } 461 | time.Sleep(time.Hour) 462 | } 463 | } 464 | 465 | func (r *downloadCommand) removeLocalFile(photoAsset *internal.PhotoAsset, livePhoto bool) error { 466 | path := photoAsset.LocalPath(photoAsset.OutputDir(r.Output, r.FolderStructure), icloudgo.PhotoVersionOriginal, r.FileStructure, livePhoto) 467 | if err := os.Remove(path); err != nil { 468 | if errors.Is(err, os.ErrNotExist) { 469 | return nil 470 | } 471 | return err 472 | } 473 | fmt.Printf("[icloudgo] [auto_delete] delete %v, %v, %v\n", photoAsset.ID(), photoAsset.Filename(livePhoto), photoAsset.FormatSize()) 474 | return nil 475 | } 476 | 477 | func (r *downloadCommand) Close() { 478 | if r.db != nil { 479 | r.db.Close() 480 | } 481 | } 482 | 483 | func (r *downloadCommand) getUnDownloadAssets() (*assertQueue, error) { 484 | assets, err := r.dalGetUnDownloadAssets(&[]int{0}[0]) 485 | if err != nil { 486 | return nil, err 487 | } else if len(assets) == 0 { 488 | return newAssertQueue(nil), nil 489 | } 490 | fmt.Printf("[icloudgo] [download] found %d undownload assets\n", len(assets)) 491 | 492 | var photoAssetList []*icloudgo.PhotoAsset 493 | for _, po := range assets { 494 | photoAssetList = append(photoAssetList, r.photoCli.NewPhotoAssetFromBytes([]byte(po.Data))) 495 | } 496 | sort.SliceStable(photoAssetList, func(i, j int) bool { 497 | return photoAssetList[i].Size() < photoAssetList[j].Size() 498 | }) 499 | 500 | return newAssertQueue(photoAssetList), nil 501 | } 502 | 503 | type assertQueue struct { 504 | recentAssets []*icloudgo.PhotoAsset 505 | recentIndex int 506 | 507 | oldAssets []*icloudgo.PhotoAsset 508 | lowIndex int 509 | highIndex int 510 | lock *sync.Mutex 511 | } 512 | 513 | func newAssertQueue(data []*icloudgo.PhotoAsset) *assertQueue { 514 | // 2天前的时间 515 | now := time.Now() 516 | twoDaysAge := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Add(-time.Hour * 24 * 2) 517 | // 区分热数据, 老数据 518 | recentAssets := []*icloudgo.PhotoAsset{} 519 | oldAssets := []*icloudgo.PhotoAsset{} 520 | for _, v := range data { 521 | if v.AssetDate().Before(twoDaysAge) { 522 | oldAssets = append(oldAssets, v) 523 | } else { 524 | recentAssets = append(recentAssets, v) 525 | } 526 | } 527 | return &assertQueue{ 528 | recentAssets: recentAssets, 529 | recentIndex: -1, 530 | oldAssets: oldAssets, 531 | lowIndex: -1, 532 | highIndex: len(oldAssets), 533 | lock: new(sync.Mutex), 534 | } 535 | } 536 | 537 | func (r *assertQueue) pick(percent float32) (*icloudgo.PhotoAsset, string) { 538 | r.lock.Lock() 539 | defer r.lock.Unlock() 540 | 541 | // 30% 的概率从 [热数据] 中选取 542 | if percent <= 0.3 { 543 | r.recentIndex++ 544 | if r.recentIndex < len(r.recentAssets) { 545 | return r.recentAssets[r.recentIndex], "recent" 546 | } 547 | } 548 | 549 | // 20% ~ 50% 的概率从 [小数据] 中选取 550 | if percent <= 0.5 { 551 | r.lowIndex++ 552 | if r.lowIndex < r.highIndex { 553 | return r.oldAssets[r.lowIndex], "small" 554 | } 555 | return nil, "" 556 | } 557 | 558 | // 50% ~ 80% 的概率从 [大数据] 中选取 559 | r.highIndex-- 560 | if r.highIndex > r.lowIndex { 561 | return r.oldAssets[r.highIndex], "big" 562 | } 563 | return nil, "" 564 | } 565 | 566 | func (r *assertQueue) empty() bool { 567 | r.lock.Lock() 568 | defer r.lock.Unlock() 569 | return r.highIndex-1 <= r.lowIndex && r.recentIndex >= len(r.recentAssets)-1 570 | } 571 | 572 | func (r *assertQueue) len() int { 573 | r.lock.Lock() 574 | defer r.lock.Unlock() 575 | return (r.highIndex - 1 - r.lowIndex) + 1 + (len(r.recentAssets) - 1 - r.recentIndex) 576 | } 577 | -------------------------------------------------------------------------------- /icloud-photo-cli/command/command_list_db.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/chyroc/icloudgo" 8 | "github.com/dgraph-io/badger/v3" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func NewListDBFlag() []cli.Flag { 13 | return []cli.Flag{ 14 | &cli.StringFlag{ 15 | Name: "cookie-dir", 16 | Usage: "cookie dir", 17 | Required: false, 18 | Aliases: []string{"c"}, 19 | EnvVars: []string{"ICLOUD_COOKIE_DIR"}, 20 | }, 21 | } 22 | } 23 | 24 | func ListDB(c *cli.Context) error { 25 | cli, err := icloudgo.New(&icloudgo.ClientOption{ 26 | Domain: "cn", 27 | CookieDir: c.String("cookie-dir"), 28 | }) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | dbPath := cli.ConfigPath("badger.db") 34 | fmt.Println("db.path", dbPath) 35 | db, err := badger.Open(badger.DefaultOptions(dbPath)) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | r := &downloadCommand{ 41 | db: db, 42 | lock: new(sync.Mutex), 43 | } 44 | defer r.Close() 45 | 46 | pos, err := r.dalGetUnDownloadAssets(nil) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | for _, v := range pos { 52 | fmt.Printf("id: %s, name: %s, status: %d\n", v.ID, v.Name, v.Status) 53 | } 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /icloud-photo-cli/command/command_upload.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/chyroc/icloudgo/internal" 9 | 10 | "github.com/urfave/cli/v2" 11 | 12 | "github.com/chyroc/icloudgo" 13 | ) 14 | 15 | func NewUploadFlag() []cli.Flag { 16 | var res []cli.Flag 17 | res = append(res, commonFlag...) 18 | res = append(res, 19 | &cli.StringFlag{ 20 | Name: "file", 21 | Usage: "file path", 22 | Required: true, 23 | Aliases: []string{"f"}, 24 | EnvVars: []string{"ICLOUD_FILE"}, 25 | }, 26 | ) 27 | return res 28 | } 29 | 30 | func Upload(c *cli.Context) error { 31 | username := c.String("username") 32 | password := c.String("password") 33 | cookieDir := c.String("cookie-dir") 34 | domain := c.String("domain") 35 | file := c.String("file") 36 | 37 | cli, err := icloudgo.New(&icloudgo.ClientOption{ 38 | AppID: username, 39 | Password: password, 40 | CookieDir: cookieDir, 41 | TwoFACodeGetter: &internal.StdinTextGetter{Tip: "2fa code"}, 42 | Domain: domain, 43 | }) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | defer cli.Close() 49 | 50 | if err := cli.Authenticate(false, nil); err != nil { 51 | return err 52 | } 53 | 54 | photoCli, err := cli.PhotoCli() 55 | if err != nil { 56 | return err 57 | } 58 | 59 | basename := filepath.Base(file) 60 | f, err := os.Open(file) 61 | if err != nil { 62 | return err 63 | } 64 | defer f.Close() 65 | 66 | isDuplicate, err := photoCli.Upload(basename, f) 67 | if err != nil { 68 | return err 69 | } 70 | if isDuplicate { 71 | fmt.Printf("file %s is duplicate\n", basename) 72 | } 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /icloud-photo-cli/command/common.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | var commonFlag = []cli.Flag{ 10 | &cli.StringFlag{ 11 | Name: "username", 12 | Usage: "apple id username", 13 | Required: true, 14 | Aliases: []string{"u"}, 15 | EnvVars: []string{"ICLOUD_USERNAME"}, 16 | }, 17 | &cli.StringFlag{ 18 | Name: "password", 19 | Usage: "apple id password", 20 | Required: false, 21 | Aliases: []string{"p"}, 22 | EnvVars: []string{"ICLOUD_PASSWORD"}, 23 | }, 24 | &cli.StringFlag{ 25 | Name: "cookie-dir", 26 | Usage: "cookie dir", 27 | Required: false, 28 | Aliases: []string{"c"}, 29 | EnvVars: []string{"ICLOUD_COOKIE_DIR"}, 30 | }, 31 | &cli.StringFlag{ 32 | Name: "domain", 33 | Usage: "icloud domain(com,cn)", 34 | Required: false, 35 | DefaultText: "com", 36 | Aliases: []string{"d"}, 37 | EnvVars: []string{"ICLOUD_DOMAIN"}, 38 | Action: func(context *cli.Context, s string) error { 39 | if s != "com" && s != "cn" && s != "" { 40 | return fmt.Errorf("domain must be com or cn") 41 | } 42 | return nil 43 | }, 44 | }, 45 | } 46 | -------------------------------------------------------------------------------- /icloud-photo-cli/command/model.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/chyroc/icloudgo" 11 | "github.com/dgraph-io/badger/v3" 12 | ) 13 | 14 | type PhotoAssetModel struct { 15 | ID string `gorm:"column:id; index:uniq_id,unique"` 16 | Name string `gorm:"column:name"` 17 | Data string `gorm:"column:data"` 18 | Status int `gorm:"column:status"` 19 | } 20 | 21 | func (r PhotoAssetModel) bytes() []byte { 22 | val, _ := json.Marshal(r) 23 | return val 24 | } 25 | 26 | func valToPhotoAssetModel(val []byte) (*PhotoAssetModel, error) { 27 | res := new(PhotoAssetModel) 28 | return res, json.Unmarshal(val, res) 29 | } 30 | 31 | func (r *downloadCommand) dalAddAssets(assets []*icloudgo.PhotoAsset) error { 32 | r.lock.Lock() 33 | defer r.lock.Unlock() 34 | return r.db.Update(func(txn *badger.Txn) error { 35 | for _, v := range assets { 36 | po := &PhotoAssetModel{ 37 | ID: v.ID(), 38 | Data: string(v.Bytes()), 39 | Status: 0, 40 | } 41 | if err := txn.Set(r.keyAssert(v.ID()), po.bytes()); err != nil { 42 | return err 43 | } 44 | } 45 | return nil 46 | }) 47 | } 48 | 49 | func (r *downloadCommand) dalDeleteAsset(id string) error { 50 | r.lock.Lock() 51 | defer r.lock.Unlock() 52 | 53 | return r.db.Update(func(txn *badger.Txn) error { 54 | return txn.Delete(r.keyAssert(id)) 55 | }) 56 | } 57 | 58 | func (r *downloadCommand) dalGetUnDownloadAssets(status *int) ([]*PhotoAssetModel, error) { 59 | r.lock.Lock() 60 | defer r.lock.Unlock() 61 | 62 | pos := []*PhotoAssetModel{} 63 | err := r.db.Update(func(txn *badger.Txn) error { 64 | it := txn.NewIterator(badger.DefaultIteratorOptions) 65 | defer it.Close() 66 | for it.Seek(r.keyAssertPrefix()); it.ValidForPrefix(r.keyAssertPrefix()); it.Next() { 67 | val, err := it.Item().ValueCopy(nil) 68 | if err != nil { 69 | return err 70 | } 71 | po, err := valToPhotoAssetModel(val) 72 | if err != nil { 73 | return err 74 | } 75 | if status == nil { 76 | pos = append(pos, po) 77 | } else if po.Status == *status { 78 | pos = append(pos, po) 79 | } 80 | } 81 | return nil 82 | }) 83 | 84 | return pos, err 85 | } 86 | 87 | func (r *downloadCommand) dalSetDownloaded(id string) error { 88 | r.lock.Lock() 89 | defer r.lock.Unlock() 90 | 91 | return r.db.Update(func(txn *badger.Txn) error { 92 | item, err := txn.Get(r.keyAssert(id)) 93 | if err != nil { 94 | return err 95 | } 96 | val, err := item.ValueCopy(nil) 97 | if err != nil { 98 | return err 99 | } 100 | po, err := valToPhotoAssetModel(val) 101 | if err != nil { 102 | return err 103 | } 104 | po.Status = 1 105 | return txn.Set(r.keyAssert(id), po.bytes()) 106 | }) 107 | } 108 | 109 | func (r *downloadCommand) keyAssertPrefix() []byte { 110 | return []byte("assert_") 111 | } 112 | 113 | func (r *downloadCommand) keyAssert(id string) []byte { 114 | return []byte("assert_" + id) 115 | } 116 | 117 | func (r *downloadCommand) dalGetDownloadOffset(albumSize int64) int64 { 118 | r.lock.Lock() 119 | defer r.lock.Unlock() 120 | 121 | var result int64 122 | _ = r.db.Update(func(txn *badger.Txn) error { 123 | offset, err := r.getDownloadOffset(txn, false) 124 | if err != nil { 125 | if errors.Is(err, badger.ErrKeyNotFound) { 126 | return nil 127 | } 128 | fmt.Printf("[icloudgo] [offset] get db offset err: %s, reset to 0\n", err) 129 | return nil 130 | } 131 | fmt.Printf("[icloudgo] [offset] get db offset: %d\n", offset) 132 | if offset > albumSize { 133 | result = 0 134 | if err = r.saveDownloadOffset(txn, 0, false); err != nil { 135 | fmt.Printf("[icloudgo] [offset] db offset=%d, album_size=%d, reset to 0, and save_db failed: %s\n", offset, albumSize, err) 136 | } else { 137 | fmt.Printf("[icloudgo] [offset] db offset=%d, album_size=%d, reset to 0\n", offset, albumSize) 138 | } 139 | } 140 | result = offset 141 | return nil 142 | }) 143 | return result 144 | } 145 | 146 | func (r *downloadCommand) getDownloadOffset(txn *badger.Txn, needLock bool) (int64, error) { 147 | if needLock { 148 | r.lock.Lock() 149 | defer r.lock.Unlock() 150 | } 151 | item, err := txn.Get(r.keyOffset()) 152 | if err != nil { 153 | return 0, err 154 | } else if item.IsDeletedOrExpired() { 155 | return 0, badger.ErrKeyNotFound 156 | } 157 | val, err := item.ValueCopy(nil) 158 | if err != nil { 159 | return 0, err 160 | } 161 | return strconv.ParseInt(string(val), 10, 64) 162 | } 163 | 164 | func (r *downloadCommand) saveDownloadOffset(txn *badger.Txn, offset int64, needLock bool) error { 165 | if needLock { 166 | r.lock.Lock() 167 | defer r.lock.Unlock() 168 | } 169 | if txn == nil { 170 | return r.db.Update(func(txn *badger.Txn) error { 171 | e := badger.NewEntry(r.keyOffset(), []byte(strconv.FormatInt(offset, 10))) 172 | e.ExpiresAt = uint64(time.Now().Add(time.Hour * 12).Unix()) 173 | return txn.SetEntry(e) 174 | }) 175 | } 176 | e := badger.NewEntry(r.keyOffset(), []byte(strconv.FormatInt(offset, 10))) 177 | e.ExpiresAt = uint64(time.Now().Add(time.Hour * 12).Unix()) 178 | return txn.SetEntry(e) 179 | } 180 | 181 | func (r *downloadCommand) keyOffset() []byte { 182 | return []byte("download_offset_" + r.AlbumName) 183 | } 184 | -------------------------------------------------------------------------------- /icloud-photo-cli/command/util.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func mkdirAll(path string) error { 8 | if f, _ := os.Stat(path); f == nil { 9 | if err := os.MkdirAll(path, os.ModePerm); err != nil { 10 | return err 11 | } 12 | } 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /icloud-photo-cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/urfave/cli/v2" 8 | 9 | "github.com/chyroc/icloudgo/icloud-photo-cli/command" 10 | ) 11 | 12 | func main() { 13 | app := &cli.App{ 14 | Name: "icloud-photo-cli", 15 | Usage: "icloud photo cli", 16 | Commands: []*cli.Command{ 17 | { 18 | Name: "download", 19 | Aliases: []string{"d"}, 20 | Description: "download photos", 21 | Flags: command.NewDownloadFlag(), 22 | Action: command.Download, 23 | }, 24 | { 25 | Name: "upload", 26 | Aliases: []string{"u"}, 27 | Description: "upload photos", 28 | Flags: command.NewUploadFlag(), 29 | Action: command.Upload, 30 | }, 31 | { 32 | Name: "list-db", 33 | Aliases: []string{"ld"}, 34 | Description: "list database datas", 35 | Flags: command.NewListDBFlag(), 36 | Action: command.ListDB, 37 | }, 38 | }, 39 | } 40 | if err := app.Run(os.Args); err != nil { 41 | log.Fatalln(err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /icloud.go: -------------------------------------------------------------------------------- 1 | package icloudgo 2 | 3 | import ( 4 | "github.com/chyroc/icloudgo/internal" 5 | ) 6 | 7 | func New(option *ClientOption) (*Client, error) { 8 | return internal.NewClient(option) 9 | } 10 | 11 | type ( 12 | TextGetter func(appleID string) (string, error) 13 | Client = internal.Client 14 | ClientOption = internal.ClientOption 15 | Error = internal.Error 16 | PhotoAlbum = internal.PhotoAlbum 17 | PhotoAsset = internal.PhotoAsset 18 | PhotoService = internal.PhotoService 19 | ) 20 | 21 | var ( 22 | ErrValidateCodeWrong = internal.ErrValidateCodeWrong 23 | ErrPhotosIterateEnd = internal.ErrPhotosIterateEnd 24 | ) 25 | 26 | const ( 27 | AlbumNameAll = internal.AlbumNameAll 28 | AlbumNameTimeLapse = internal.AlbumNameTimeLapse 29 | AlbumNameVideos = internal.AlbumNameVideos 30 | AlbumNameSloMo = internal.AlbumNameSloMo 31 | AlbumNameBursts = internal.AlbumNameBursts 32 | AlbumNameFavorites = internal.AlbumNameFavorites 33 | AlbumNamePanoramas = internal.AlbumNamePanoramas 34 | AlbumNameScreenshots = internal.AlbumNameScreenshots 35 | AlbumNameLive = internal.AlbumNameLive 36 | AlbumNameRecentlyDeleted = internal.AlbumNameRecentlyDeleted 37 | AlbumNameHidden = internal.AlbumNameHidden 38 | ) 39 | 40 | type PhotoVersion = internal.PhotoVersion 41 | 42 | const ( 43 | PhotoVersionOriginal = internal.PhotoVersionOriginal 44 | PhotoVersionMedium = internal.PhotoVersionMedium 45 | PhotoVersionThumb = internal.PhotoVersionThumb 46 | ) 47 | -------------------------------------------------------------------------------- /internal/auth.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func (r *Client) Authenticate(forceRefresh bool, service *string) (finalErr error) { 9 | defer func() { 10 | if finalErr == nil { 11 | r.flush() 12 | } 13 | }() 14 | 15 | var errs []string 16 | if r.sessionData.SessionToken != "" && !forceRefresh { 17 | fmt.Printf("Checking session token validity") 18 | if err := r.validateToken(); err == nil { 19 | return nil 20 | } else { 21 | errs = append(errs, err.Error()) 22 | fmt.Printf("Invalid session token. Attempting brand new login.\n") 23 | } 24 | } 25 | 26 | if service != nil { 27 | if r.Data != nil && len(r.Data.Apps) > 0 && r.Data.Apps[*service] != nil && r.Data.Apps[*service].CanLaunchWithOneFactor { 28 | fmt.Printf("Authenticating as %s for %s\n", r.appleID, *service) 29 | if err := r.authWithCredentialsService(*service, r.password); err != nil { 30 | errs = append(errs, err.Error()) 31 | fmt.Printf("Could not log into service. Attempting brand new login.\n") 32 | } else { 33 | return nil 34 | } 35 | } 36 | } 37 | 38 | // default, login to icloud.com[.cn] 39 | { 40 | fmt.Printf("Authenticating as %s\n", r.appleID) 41 | err := r.signIn(r.password) 42 | if err == nil { 43 | err = r.verify2Fa() 44 | if err == nil { 45 | return nil 46 | } 47 | } 48 | // self._webservices = self.data["webservices"] 49 | errs = append(errs, err.Error()) 50 | fmt.Printf("Login failed\n") 51 | } 52 | 53 | return fmt.Errorf("login failed: %s", strings.Join(errs, "; ")) 54 | } 55 | -------------------------------------------------------------------------------- /internal/auth_signin.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func (r *Client) signIn(password string) error { 9 | body := map[string]any{ 10 | "accountName": r.appleID, 11 | "password": password, 12 | "rememberMe": true, 13 | "trustTokens": []string{}, 14 | } 15 | if r.sessionData.TrustToken != "" { 16 | body["trustTokens"] = []string{r.sessionData.TrustToken} 17 | } 18 | 19 | headers := r.getAuthHeaders(map[string]string{}) 20 | headers = setIfNotEmpty(headers, "scnt", r.sessionData.Scnt) 21 | headers = setIfNotEmpty(headers, "X-Apple-ID-Session-Id", r.sessionData.SessionID) 22 | 23 | _, err := r.request(&rawReq{ 24 | Method: http.MethodPost, 25 | URL: r.authEndpoint + "/signin", 26 | Headers: headers, 27 | Querys: map[string]string{"isRememberMeEnabled": "true"}, 28 | Body: body, 29 | ExpectStatus: newSet[int](http.StatusOK), 30 | }) 31 | if err != nil { 32 | return fmt.Errorf("signin failed: %w", err) 33 | } 34 | 35 | return r.authWithToken() 36 | } 37 | -------------------------------------------------------------------------------- /internal/auth_trust_session.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // session trust to avoid user log in going forward 9 | func (r *Client) trustSession() error { 10 | headers := r.getAuthHeaders(map[string]string{}) 11 | headers = setIfNotEmpty(headers, "scnt", r.sessionData.Scnt) 12 | headers = setIfNotEmpty(headers, "X-Apple-ID-Session-Id", r.sessionData.SessionID) 13 | 14 | _, err := r.request(&rawReq{ 15 | Method: http.MethodGet, 16 | URL: r.authEndpoint + "/2sv/trust", 17 | Headers: headers, 18 | ExpectStatus: newSet[int](http.StatusNoContent), 19 | }) 20 | if err != nil { 21 | return fmt.Errorf("trustSession failed: %w", err) 22 | } 23 | 24 | return r.authWithToken() 25 | } 26 | -------------------------------------------------------------------------------- /internal/auth_trusted_deviced.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | // Returns devices trusted for two-step authentication. 10 | func (r *Client) trustedDevices() ([]*device, error) { 11 | text, err := r.request(&rawReq{ 12 | Method: http.MethodPost, 13 | URL: r.setupEndpoint + "/listDevices", 14 | Headers: r.getCommonHeaders(map[string]string{}), 15 | }) 16 | if err != nil { 17 | return nil, fmt.Errorf("listDevices failed, err: %w", err) 18 | } 19 | res := new(trustedDevicesResp) 20 | if err = json.Unmarshal([]byte(text), res); err != nil { 21 | return nil, fmt.Errorf("listDevices unmarshal failed, text: %s", text) 22 | } 23 | return res.Devices, nil 24 | } 25 | 26 | type trustedDevicesResp struct { 27 | Devices []*device `json:"devices"` 28 | } 29 | 30 | type device struct { 31 | DeviceName string `json:"deviceName"` 32 | PhoneNumber string `json:"phoneNumber"` 33 | } 34 | 35 | func (r *device) GetName() string { 36 | if r.DeviceName != "" { 37 | return r.DeviceName 38 | } 39 | return fmt.Sprintf("SMS to %s", r.PhoneNumber) 40 | } 41 | -------------------------------------------------------------------------------- /internal/auth_validate_code.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func (r *Client) validate2FACode(code string) error { 9 | body := map[string]interface{}{"securityCode": map[string]string{"code": code}} 10 | 11 | headers := r.getAuthHeaders(map[string]string{"Accept": "application/json"}) 12 | headers = setIfNotEmpty(headers, "scnt", r.sessionData.Scnt) 13 | headers = setIfNotEmpty(headers, "X-Apple-ID-Session-Id", r.sessionData.SessionID) 14 | 15 | if _, err := r.request(&rawReq{ 16 | Method: http.MethodPost, 17 | URL: r.authEndpoint + "/verify/trusteddevice/securitycode", 18 | Headers: headers, 19 | Body: body, 20 | ExpectStatus: newSet[int](http.StatusNoContent), 21 | }); err != nil { 22 | if IsErrorCode(err, ErrValidateCodeWrong.Code) { 23 | return ErrValidateCodeWrong 24 | } 25 | return fmt.Errorf("validate2FACode failed: %w", err) 26 | } 27 | 28 | if err := r.trustSession(); err != nil { 29 | return err 30 | } 31 | 32 | if r.isRequires2FA() { 33 | return fmt.Errorf("2FA is still required after validate2FACode") 34 | } 35 | 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /internal/auth_validate_token.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | func (r *Client) validateToken() error { 10 | fmt.Printf("Checking session token validity\n") 11 | 12 | text, err := r.request(&rawReq{ 13 | Method: http.MethodPost, 14 | URL: r.setupEndpoint + "/validate", 15 | Headers: r.getCommonHeaders(map[string]string{}), 16 | }) 17 | if err != nil { 18 | return fmt.Errorf("validateToken failed, err: %w", err) 19 | } 20 | 21 | res := new(ValidateData) 22 | if err = json.Unmarshal([]byte(text), res); err != nil { 23 | return fmt.Errorf("validateToken unmarshal failed, err: %w, text: %s", err, text) 24 | } 25 | r.Data = res 26 | 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /internal/auth_verify_2fa.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func (r *Client) verify2Fa() error { 9 | if r.Data == nil || r.Data.DsInfo == nil { 10 | return fmt.Errorf("not authenticated validate data") 11 | } 12 | 13 | if r.isRequires2FA() { 14 | code, err := r.twoFACodeGetter.GetText(r.appleID) 15 | if err != nil { 16 | return fmt.Errorf("get 2fa code failed, err: %w", err) 17 | } 18 | if err := r.validate2FACode(code); err != nil { 19 | return err 20 | } 21 | 22 | if !r.Data.HsaTrustedBrowser { 23 | if err := r.trustSession(); err != nil { 24 | return err 25 | } 26 | } 27 | } else if r.isRequires2SA() { 28 | fmt.Printf("Two-step authentication required. Your trusted devices are:\n") 29 | devices, err := r.trustedDevices() 30 | if err != nil { 31 | return err 32 | } 33 | for i, device := range devices { 34 | fmt.Printf(" %d: %s\n", i, device.GetName()) 35 | } 36 | 37 | fmt.Printf("not impl") 38 | os.Exit(1) 39 | } 40 | return nil 41 | } 42 | 43 | func (r *Client) isRequires2FA() bool { 44 | return r.Data.DsInfo.HsaVersion == 2 && (r.Data.HsaChallengeRequired || !r.Data.HsaTrustedBrowser) 45 | } 46 | 47 | func (r *Client) isRequires2SA() bool { 48 | return r.Data.DsInfo.HsaVersion >= 1 && (r.Data.HsaChallengeRequired || !r.Data.HsaTrustedBrowser) 49 | } 50 | -------------------------------------------------------------------------------- /internal/auth_with_credentials_service.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func (r *Client) authWithCredentialsService(service, password string) error { 9 | _, err := r.request(&rawReq{ 10 | Method: http.MethodPost, 11 | URL: r.setupEndpoint + "/accountLogin", 12 | Headers: r.getCommonHeaders(map[string]string{}), 13 | Body: map[string]string{ 14 | "appName": service, 15 | "apple_id": r.appleID, 16 | "password": password, 17 | }, 18 | }) 19 | if err != nil { 20 | return fmt.Errorf("authWithCredentialsService failed, err: %w", err) 21 | } 22 | 23 | return r.validateToken() 24 | } 25 | -------------------------------------------------------------------------------- /internal/auth_with_token.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | // auth using session token 10 | func (r *Client) authWithToken() error { 11 | text, err := r.request(&rawReq{ 12 | Method: http.MethodPost, 13 | URL: r.setupEndpoint + "/accountLogin", 14 | Headers: r.getCommonHeaders(map[string]string{}), 15 | Body: map[string]any{ 16 | "accountCountryCode": r.sessionData.AccountCountry, 17 | "dsWebAuthToken": r.sessionData.SessionToken, 18 | "extended_login": true, 19 | "trustToken": r.sessionData.TrustToken, 20 | }, 21 | ExpectStatus: newSet[int](200), 22 | }) 23 | if err != nil { 24 | return fmt.Errorf("authWithToken failed, err: %w", err) 25 | } 26 | 27 | data := new(ValidateData) 28 | if err = json.Unmarshal([]byte(text), data); err != nil { 29 | return fmt.Errorf("authWithToken unmarshal failed, text: %s", text) 30 | } 31 | r.Data = data 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/client.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/chyroc/gorequests" 9 | uuid "github.com/satori/go.uuid" 10 | ) 11 | 12 | type TextGetter interface { 13 | GetText(tip string) (string, error) 14 | } 15 | 16 | type Client struct { 17 | // param 18 | appleID string 19 | password string 20 | twoFACodeGetter TextGetter 21 | 22 | // storage 23 | cookieDir string 24 | cookiePath string 25 | clientIDPath string 26 | sessionDataPath string 27 | 28 | // user data 29 | clientID string 30 | sessionData *SessionData 31 | Data *ValidateData 32 | httpCli *gorequests.Session 33 | 34 | // server 35 | setupEndpoint string 36 | homeEndpoint string 37 | authEndpoint string 38 | 39 | // service 40 | photo *PhotoService 41 | drive *DriveService 42 | } 43 | 44 | type ClientOption struct { 45 | AppID string 46 | Password string 47 | CookieDir string 48 | TwoFACodeGetter TextGetter 49 | Domain string // com,cn 50 | } 51 | 52 | func NewClient(option *ClientOption) (*Client, error) { 53 | return newClient(option) 54 | } 55 | 56 | func newClient(option *ClientOption) (*Client, error) { 57 | cli := &Client{ 58 | twoFACodeGetter: option.TwoFACodeGetter, 59 | } 60 | var err error 61 | 62 | // domain 63 | if option.Domain == "cn" { 64 | cli.setupEndpoint = "https://setup.icloud.com.cn/setup/ws/1" 65 | cli.homeEndpoint = "https://www.icloud.com.cn" 66 | cli.authEndpoint = "https://idmsa.apple.com/appleauth/auth" 67 | } else if option.Domain == "com" { 68 | cli.setupEndpoint = "https://setup.icloud.com/setup/ws/1" 69 | cli.homeEndpoint = "https://www.icloud.com" 70 | cli.authEndpoint = "https://idmsa.apple.com/appleauth/auth" 71 | } else { 72 | return nil, fmt.Errorf("invalid domain: %s", option.Domain) 73 | } 74 | 75 | // storage 76 | { 77 | cli.cookieDir, err = cli.initCookieDir(option.CookieDir) 78 | if err != nil { 79 | return nil, err 80 | } 81 | cli.cookiePath = cli.ConfigPath("cookie.json") 82 | cli.clientIDPath = cli.ConfigPath("client_id.txt") 83 | cli.sessionDataPath = cli.ConfigPath("session_data.json") 84 | } 85 | 86 | // load from file 87 | { 88 | // client id 89 | if clientIDCached := readFile(cli.clientIDPath); len(clientIDCached) > 0 { 90 | cli.clientID = string(clientIDCached) 91 | } else { 92 | cli.clientID = "auth-" + uuid.NewV1().String() 93 | } 94 | 95 | // session data 96 | cli.sessionData = new(SessionData) 97 | if sessionDataCached := readFile(cli.sessionDataPath); len(sessionDataCached) > 0 { 98 | _ = json.Unmarshal(sessionDataCached, cli.sessionData) 99 | } 100 | 101 | // data 102 | cli.Data = new(ValidateData) 103 | } 104 | 105 | cli.appleID = option.AppID 106 | 107 | cli.httpCli = gorequests.NewSession( 108 | fmt.Sprintf("%s/session.json", cli.cookieDir), 109 | gorequests.WithLogger(gorequests.NewDiscardLogger()), 110 | ) 111 | 112 | return cli, nil 113 | } 114 | 115 | func readFile(path string) []byte { 116 | bs, _ := os.ReadFile(path) 117 | return bs 118 | } 119 | 120 | const ( 121 | serviceReminders = "reminders" 122 | serviceDatabase = "ckdatabasews" 123 | servicePhotosUpload = "photosupload" 124 | servicePhotos = "photos" 125 | serviceDrive = "drivews" 126 | serviceUploadImage = "uploadimagews" 127 | serviceCkShare = "cksharews" 128 | serviceFindMe = "findme" 129 | serviceCkDeviceService = "ckdeviceservice" 130 | serviceIWorkThumbnail = "iworkthumbnailws" 131 | serviceMccGateway = "mccgateway" 132 | serviceCalendar = "calendar" 133 | serviceDoc = "docws" 134 | serviceSettings = "settings" 135 | servicePremiumMailSettings = "premiummailsettings" 136 | serviceUbiquity = "ubiquity" 137 | serviceKeyValue = "keyvalue" 138 | serviceMpp = "mpp" 139 | serviceArchive = "archivews" 140 | servicePush = "push" 141 | serviceIWMB = "iwmb" 142 | serviceIworkExport = "iworkexportws" 143 | serviceSharedLibrary = "sharedlibrary" 144 | serviceGeo = "geows" 145 | serviceAccount = "account" 146 | serviceContacts = "contacts" 147 | serviceDeveloperApi = "developerapi" 148 | ) 149 | 150 | func (r *Client) getWebServiceURL(key string) (string, error) { 151 | if _, ok := r.Data.Webservices[key]; !ok { 152 | return "", fmt.Errorf("webservice not available: %s", key) 153 | } 154 | return r.Data.Webservices[key].URL, nil 155 | } 156 | -------------------------------------------------------------------------------- /internal/close.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | ) 7 | 8 | func (r *Client) Close() error { 9 | if err := r.flush(); err != nil { 10 | return err 11 | } 12 | 13 | return nil 14 | } 15 | 16 | func (r *Client) flush() error { 17 | if r.clientID != "" { 18 | if err := os.WriteFile(r.clientIDPath, []byte(r.clientID), 0o644); err != nil { 19 | return err 20 | } 21 | } 22 | 23 | if r.sessionData.SessionToken != "" { 24 | if bs, _ := json.Marshal(r.sessionData); len(bs) > 0 { 25 | if err := os.WriteFile(r.sessionDataPath, bs, 0o644); err != nil { 26 | return err 27 | } 28 | } 29 | } 30 | 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /internal/config.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | func (r *Client) ConfigPath(configName string) string { 11 | return filepath.Join(r.cookieDir, configName) 12 | } 13 | 14 | func (r *Client) LoadConfig(configName string) ([]byte, error) { 15 | bs, err := os.ReadFile(r.ConfigPath(configName)) 16 | if err != nil { 17 | if errors.Is(err, os.ErrNotExist) { 18 | return nil, nil 19 | } 20 | return nil, err 21 | } 22 | return bs, nil 23 | } 24 | 25 | func (r *Client) SaveConfig(configName string, data []byte) error { 26 | return os.WriteFile(r.ConfigPath(configName), data, 0o644) 27 | } 28 | 29 | func (r *Client) initCookieDir(defaultValue string) (string, error) { 30 | if defaultValue == "" { 31 | defaultValue = filepath.Join(os.TempDir(), "icloudgo") 32 | } 33 | 34 | if f, _ := os.Stat(defaultValue); f == nil { 35 | if err := os.MkdirAll(defaultValue, 0o700); err != nil { 36 | return "", fmt.Errorf("create cookie dir failed, err: %w", err) 37 | } 38 | } 39 | return defaultValue, nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/drive_cli.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | type DriveService struct { 8 | icloud *Client 9 | serviceRoot string 10 | serviceEndpoint string 11 | 12 | lock *sync.Mutex 13 | } 14 | 15 | func (r *Client) DriveCli() (*DriveService, error) { 16 | if r.drive == nil { 17 | driveWS, err := r.getWebServiceURL(serviceDrive) 18 | if err != nil { 19 | return nil, err 20 | } 21 | r.drive, err = newDriveService(r, driveWS) 22 | if err != nil { 23 | return nil, err 24 | } 25 | } 26 | return r.drive, nil 27 | } 28 | 29 | func newDriveService(icloud *Client, serviceRoot string) (*DriveService, error) { 30 | photoCli := &DriveService{ 31 | icloud: icloud, 32 | serviceRoot: serviceRoot, 33 | serviceEndpoint: serviceRoot, 34 | 35 | lock: new(sync.Mutex), 36 | } 37 | 38 | return photoCli, nil 39 | } 40 | -------------------------------------------------------------------------------- /internal/error.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | ErrValidateCodeWrong = NewError("-21669", "validate code wrong") 12 | ErrPhotosIterateEnd = NewError("photos_iterate_end", "photos iterate end") 13 | ErrResourceGone = NewHttpError(410, "resource gone") 14 | ) 15 | 16 | type Error struct { 17 | HttpStatus int 18 | Code string 19 | Message string 20 | } 21 | 22 | func (e *Error) Error() string { 23 | return fmt.Sprintf("%s: %s", e.Code, e.Message) 24 | } 25 | 26 | func NewHttpError(httpStatus int, body string) *Error { 27 | return &Error{ 28 | HttpStatus: httpStatus, 29 | Code: fmt.Sprintf("http_%d", httpStatus), 30 | Message: body, 31 | } 32 | } 33 | 34 | func NewError(code string, message string) *Error { 35 | return &Error{ 36 | Code: code, 37 | Message: message, 38 | } 39 | } 40 | 41 | func IsErrorCode(err error, code string) bool { 42 | if err == nil { 43 | return false 44 | } 45 | if e, ok := err.(*Error); ok { 46 | return e.Code == code 47 | } 48 | return IsErrorCode(errors.Unwrap(err), code) 49 | } 50 | 51 | func mayErr(respText []byte) error { 52 | for _, errResp := range []interface{ err() error }{new(errResp1), new(errResp2), new(errResp3), new(errResp4)} { 53 | if err := json.Unmarshal(respText, errResp); err == nil && errResp.err() != nil { 54 | return errResp.err() 55 | } 56 | } 57 | return nil 58 | } 59 | 60 | // {"service_errors":[{"code":"-21669","title":"Incorrect verification code.","message":"Please try again."}],"hasError":true} 61 | type errResp1 struct { 62 | ServiceErrors []struct { 63 | Code string `json:"code"` 64 | Title string `json:"title"` 65 | Message string `json:"message"` 66 | } `json:"service_errors"` 67 | HasError bool `json:"hasError"` 68 | } 69 | 70 | func (r errResp1) err() error { 71 | for _, v := range r.ServiceErrors { 72 | if v.Code == "" || v.Code == "0" { 73 | continue 74 | } 75 | text1 := strings.Trim(v.Title, ".") 76 | text2 := strings.Trim(v.Message, ".") 77 | 78 | if !strings.EqualFold(text1, text2) { 79 | text1 = text1 + ", " + text2 80 | } 81 | 82 | return NewError(v.Code, text1) 83 | } 84 | if r.HasError { 85 | bs, _ := json.Marshal(r) 86 | return NewError("1", "unknown error: "+string(bs)) 87 | } 88 | return nil 89 | } 90 | 91 | type errResp2 struct { 92 | Reason string `json:"reason"` 93 | Error string `json:"error"` 94 | } 95 | 96 | func (r errResp2) err() error { 97 | if r.Error != "" { 98 | if r.Reason == "" { 99 | return NewError("-2", r.Error) 100 | } 101 | return NewError("-2", r.Error+" "+r.Reason) 102 | } 103 | return nil 104 | } 105 | 106 | type errResp3 struct { 107 | Reason string `json:"reason"` 108 | Error int `json:"error"` 109 | } 110 | 111 | func (r errResp3) err() error { 112 | if r.Reason == "" { 113 | return nil 114 | } 115 | return NewError(fmt.Sprintf("%d", r.Error), r.Reason) 116 | } 117 | 118 | // {"errors":[{"errorCode":"CLOUD_DB_FAILURE"}],"requestUUID":"fb28547f-3785-4a4f-903c-13b51aa236a9"} 119 | type errResp4 struct { 120 | Errors []struct { 121 | ErrorCode string `json:"errorCode"` 122 | } `json:"errors"` 123 | } 124 | 125 | func (r errResp4) err() error { 126 | if len(r.Errors) == 0 { 127 | return nil 128 | } 129 | return NewError("-2", r.Errors[0].ErrorCode) 130 | } 131 | -------------------------------------------------------------------------------- /internal/photo_album.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "sync" 7 | ) 8 | 9 | const ( 10 | AlbumNameAll = "All Photos" 11 | AlbumNameTimeLapse = "Time-lapse" 12 | AlbumNameVideos = "Videos" 13 | AlbumNameSloMo = "Slo-mo" 14 | AlbumNameBursts = "Bursts" 15 | AlbumNameFavorites = "Favorites" 16 | AlbumNamePanoramas = "Panoramas" 17 | AlbumNameScreenshots = "Screenshots" 18 | AlbumNameLive = "Live" 19 | AlbumNameRecentlyDeleted = "Recently Deleted" 20 | AlbumNameHidden = "Hidden" 21 | ) 22 | 23 | type PhotoAlbum struct { 24 | // service 25 | service *PhotoService 26 | 27 | // attr 28 | Name string 29 | ListType string 30 | ObjType string 31 | Direction string 32 | QueryFilter []*folderMetaDataQueryFilter 33 | 34 | // cache 35 | _size *int64 36 | lock *sync.Mutex 37 | } 38 | 39 | func (r *PhotoService) newPhotoAlbum(name, listType, objType, direction string, queryFilter []*folderMetaDataQueryFilter) *PhotoAlbum { 40 | return &PhotoAlbum{ 41 | service: r, 42 | 43 | Name: name, 44 | ListType: listType, 45 | ObjType: objType, 46 | Direction: direction, 47 | QueryFilter: queryFilter, 48 | 49 | _size: nil, 50 | lock: new(sync.Mutex), 51 | } 52 | } 53 | 54 | func (r *PhotoService) GetAlbum(albumName string) (*PhotoAlbum, error) { 55 | albums, err := r.Albums() 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | album := albums[AlbumNameAll] 61 | if albumName != "" { 62 | var ok bool 63 | album, ok = albums[albumName] 64 | if !ok { 65 | return nil, fmt.Errorf("album %s not found", albumName) 66 | } 67 | } 68 | return album, nil 69 | } 70 | 71 | func (r *PhotoService) Albums() (map[string]*PhotoAlbum, error) { 72 | r.lock.Lock() 73 | albumIsNil := len(r._albums) == 0 74 | r.lock.Unlock() 75 | 76 | if !albumIsNil { 77 | return r._albums, nil 78 | } 79 | 80 | tmp := map[string]*PhotoAlbum{} 81 | 82 | for name, props := range icloudPhotoFolderMeta { 83 | tmp[name] = r.newPhotoAlbum(name, props.ListType, props.ObjType, props.Direction, props.QueryFilter) 84 | } 85 | 86 | folders, err := r.getFolders() 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | for _, folder := range folders { 92 | if folder.Fields.AlbumNameEnc == nil || folder.Fields.AlbumNameEnc.Value == "" { 93 | continue 94 | } 95 | if folder.Fields.IsDeleted != nil && folder.Fields.IsDeleted.Value != "" { 96 | continue 97 | } 98 | if folder.RecordName == "----Root-Folder----" { 99 | continue 100 | } 101 | folderID := folder.RecordName 102 | folderObjType := fmt.Sprintf("CPLContainerRelationNotDeletedByAssetDate:%s", folderID) 103 | folderName, _ := base64.StdEncoding.DecodeString(folder.Fields.AlbumNameEnc.Value.(string)) 104 | if len(folderName) == 0 { 105 | continue 106 | } 107 | 108 | tmp[string(folderName)] = r.newPhotoAlbum(string(folderName), "CPLContainerRelationLiveByAssetDate", folderObjType, "ASCENDING", []*folderMetaDataQueryFilter{{ 109 | FieldName: "parentId", 110 | Comparator: "EQUALS", 111 | FieldValue: &folderTypeValue{Type: "STRING", Value: folderID}, 112 | }}) 113 | } 114 | 115 | r.lock.Lock() 116 | r._albums = tmp 117 | r.lock.Unlock() 118 | 119 | return r._albums, nil 120 | } 121 | 122 | var icloudPhotoFolderMeta = map[string]*folderMetaData{ 123 | "All Photos": { 124 | ObjType: "CPLAssetByAddedDate", 125 | ListType: "CPLAssetAndMasterByAddedDate", 126 | Direction: "ASCENDING", 127 | }, 128 | "Time-lapse": { 129 | ObjType: "CPLAssetInSmartAlbumByAssetDate:Timelapse", 130 | ListType: "CPLAssetAndMasterInSmartAlbumByAssetDate", 131 | Direction: "ASCENDING", 132 | QueryFilter: []*folderMetaDataQueryFilter{ 133 | { 134 | FieldName: "smartAlbum", 135 | Comparator: "EQUALS", 136 | FieldValue: &folderTypeValue{Type: "STRING", Value: "TIMELAPSE"}, 137 | }, 138 | }, 139 | }, 140 | "Videos": { 141 | ObjType: "CPLAssetInSmartAlbumByAssetDate:Video", 142 | ListType: "CPLAssetAndMasterInSmartAlbumByAssetDate", 143 | Direction: "ASCENDING", 144 | QueryFilter: []*folderMetaDataQueryFilter{ 145 | { 146 | FieldName: "smartAlbum", 147 | Comparator: "EQUALS", 148 | FieldValue: &folderTypeValue{Type: "STRING", Value: "VIDEO"}, 149 | }, 150 | }, 151 | }, 152 | "Slo-mo": { 153 | ObjType: "CPLAssetInSmartAlbumByAssetDate:Slomo", 154 | ListType: "CPLAssetAndMasterInSmartAlbumByAssetDate", 155 | Direction: "ASCENDING", 156 | QueryFilter: []*folderMetaDataQueryFilter{ 157 | { 158 | FieldName: "smartAlbum", 159 | Comparator: "EQUALS", 160 | FieldValue: &folderTypeValue{Type: "STRING", Value: "SLOMO"}, 161 | }, 162 | }, 163 | }, 164 | "Bursts": { 165 | ObjType: "CPLAssetBurstStackAssetByAssetDate", 166 | ListType: "CPLBurstStackAssetAndMasterByAssetDate", 167 | Direction: "ASCENDING", 168 | }, 169 | "Favorites": { 170 | ObjType: "CPLAssetInSmartAlbumByAssetDate:Favorite", 171 | ListType: "CPLAssetAndMasterInSmartAlbumByAssetDate", 172 | Direction: "ASCENDING", 173 | QueryFilter: []*folderMetaDataQueryFilter{ 174 | { 175 | FieldName: "smartAlbum", 176 | Comparator: "EQUALS", 177 | FieldValue: &folderTypeValue{Type: "STRING", Value: "FAVORITE"}, 178 | }, 179 | }, 180 | }, 181 | "Panoramas": { 182 | ObjType: "CPLAssetInSmartAlbumByAssetDate:Panorama", 183 | ListType: "CPLAssetAndMasterInSmartAlbumByAssetDate", 184 | Direction: "ASCENDING", 185 | QueryFilter: []*folderMetaDataQueryFilter{ 186 | { 187 | FieldName: "smartAlbum", 188 | Comparator: "EQUALS", 189 | FieldValue: &folderTypeValue{Type: "STRING", Value: "PANORAMA"}, 190 | }, 191 | }, 192 | }, 193 | "Screenshots": { 194 | ObjType: "CPLAssetInSmartAlbumByAssetDate:Screenshot", 195 | ListType: "CPLAssetAndMasterInSmartAlbumByAssetDate", 196 | Direction: "ASCENDING", 197 | QueryFilter: []*folderMetaDataQueryFilter{ 198 | { 199 | FieldName: "smartAlbum", 200 | Comparator: "EQUALS", 201 | FieldValue: &folderTypeValue{Type: "STRING", Value: "SCREENSHOT"}, 202 | }, 203 | }, 204 | }, 205 | "Live": { 206 | ObjType: "CPLAssetInSmartAlbumByAssetDate:Live", 207 | ListType: "CPLAssetAndMasterInSmartAlbumByAssetDate", 208 | Direction: "ASCENDING", 209 | QueryFilter: []*folderMetaDataQueryFilter{ 210 | { 211 | FieldName: "smartAlbum", 212 | Comparator: "EQUALS", 213 | FieldValue: &folderTypeValue{Type: "STRING", Value: "LIVE"}, 214 | }, 215 | }, 216 | }, 217 | "Recently Deleted": { 218 | ObjType: "CPLAssetDeletedByExpungedDate", 219 | ListType: "CPLAssetAndMasterDeletedByExpungedDate", 220 | Direction: "ASCENDING", 221 | }, 222 | "Hidden": { 223 | ObjType: "CPLAssetHiddenByAssetDate", 224 | ListType: "CPLAssetAndMasterHiddenByAssetDate", 225 | Direction: "ASCENDING", 226 | }, 227 | } 228 | 229 | type folderMetaData struct { 230 | ListType string `json:"list_type"` 231 | ObjType string `json:"obj_type"` 232 | Direction string `json:"direction"` 233 | QueryFilter []*folderMetaDataQueryFilter `json:"query_filter"` 234 | PageSize int `json:"page_size"` 235 | } 236 | 237 | type folderMetaDataQueryFilter struct { 238 | FieldName string `json:"fieldName"` 239 | Comparator string `json:"comparator"` 240 | FieldValue *folderTypeValue `json:"fieldValue"` 241 | } 242 | -------------------------------------------------------------------------------- /internal/photo_album_photos.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | func (r *PhotoAlbum) PhotosIter(startOffset int64) PhotosIterNext { 9 | if r.Direction == "DESCENDING" { 10 | startOffset = r.Size() - 1 - startOffset 11 | } 12 | return newPhotosIterNext(r, startOffset) 13 | } 14 | 15 | func (r *PhotoAlbum) GetPhotosByOffset(offset, limit int64) ([]*PhotoAsset, error) { 16 | var assets []*PhotoAsset 17 | 18 | text, err := r.service.icloud.request(&rawReq{ 19 | Method: "POST", 20 | URL: fmt.Sprintf("%s/records/query", r.service.serviceEndpoint), 21 | Querys: r.service.querys, 22 | Headers: r.service.icloud.getCommonHeaders(map[string]string{}), 23 | Body: r.listQueryGenerate(offset, limit, r.ListType, r.Direction, r.QueryFilter), 24 | }) 25 | if err != nil { 26 | return nil, fmt.Errorf("get album photos failed, err: %w", err) 27 | } 28 | res := new(getPhotosResp) 29 | if err = json.Unmarshal([]byte(text), res); err != nil { 30 | return nil, fmt.Errorf("get album photos unmarshal failed, err: %w", err) 31 | } 32 | 33 | var masterRecords []*photoRecord 34 | assetRecords := map[string]*photoRecord{} 35 | for _, record := range res.Records { 36 | if record.RecordType == "CPLAsset" { 37 | masterID := record.Fields.MasterRef.Value.RecordName 38 | assetRecords[masterID] = record 39 | } else if record.RecordType == "CPLMaster" { 40 | masterRecords = append(masterRecords, record) 41 | } 42 | } 43 | 44 | for _, masterRecord := range masterRecords { 45 | assets = append(assets, 46 | r.service.newPhotoAsset(masterRecord, assetRecords[masterRecord.RecordName]), 47 | ) 48 | } 49 | 50 | return assets, nil 51 | } 52 | 53 | func (r *PhotoAlbum) GetPhotosByCount(count int) ([]*PhotoAsset, error) { 54 | offset := int64(0) 55 | if r.Direction == "DESCENDING" { 56 | offset = r.Size() - 1 57 | } 58 | 59 | var assets []*PhotoAsset 60 | for { 61 | tmp, err := r.GetPhotosByOffset(offset, 200) 62 | if err != nil { 63 | return nil, err 64 | } 65 | if len(tmp) == 0 { 66 | break 67 | } 68 | for _, v := range tmp { 69 | assets = append(assets, v) 70 | if len(assets) >= count { 71 | return assets, nil 72 | } 73 | } 74 | offset = r.calOffset(offset, int64(len(tmp))) 75 | } 76 | 77 | return assets, nil 78 | } 79 | 80 | func (r *PhotoAlbum) WalkPhotos(offset int64, f func(offset int64, assets []*PhotoAsset) error) error { 81 | size := r.Size() 82 | if r.Direction == "DESCENDING" { 83 | offset = size - 1 - offset 84 | } 85 | for { 86 | tmp, err := r.GetPhotosByOffset(offset, 200) 87 | if err != nil { 88 | return err 89 | } 90 | fmt.Printf("[icloudgo] [walk_photo] name: %s, offset: %d, size=%d, got=%d, desc=%v\n", r.Name, offset, size, len(tmp), r.Direction == "DESCENDING") 91 | if len(tmp) == 0 { 92 | break 93 | } 94 | offset = r.calOffset(offset, int64(len(tmp))) 95 | 96 | if err := f(offset, tmp); err != nil { 97 | return err 98 | } 99 | } 100 | return nil 101 | } 102 | 103 | func (r *PhotoAlbum) calOffset(offset, lastAssetLen int64) int64 { 104 | if r.Direction == "DESCENDING" { 105 | offset = offset - lastAssetLen 106 | } else { 107 | offset = offset + lastAssetLen 108 | } 109 | return offset 110 | } 111 | 112 | func (r *PhotoAlbum) listQueryGenerate(offset, limit int64, listType string, direction string, queryFilter []*folderMetaDataQueryFilter) any { 113 | res := map[string]any{ 114 | "query": map[string]any{ 115 | "filterBy": append([]*folderMetaDataQueryFilter{ 116 | { 117 | FieldName: "startRank", 118 | FieldValue: &folderTypeValue{Type: "INT64", Value: offset}, 119 | Comparator: "EQUALS", 120 | }, 121 | { 122 | FieldName: "direction", 123 | FieldValue: &folderTypeValue{Type: "STRING", Value: direction}, 124 | Comparator: "EQUALS", 125 | }, 126 | }, queryFilter...), 127 | "recordType": listType, 128 | }, 129 | "resultsLimit": limit, 130 | "desiredKeys": []string{ 131 | "resJPEGFullWidth", 132 | "resJPEGFullHeight", 133 | "resJPEGFullFileType", 134 | "resJPEGFullFingerprint", 135 | "resJPEGFullRes", 136 | "resJPEGLargeWidth", 137 | "resJPEGLargeHeight", 138 | "resJPEGLargeFileType", 139 | "resJPEGLargeFingerprint", 140 | "resJPEGLargeRes", 141 | "resJPEGMedWidth", 142 | "resJPEGMedHeight", 143 | "resJPEGMedFileType", 144 | "resJPEGMedFingerprint", 145 | "resJPEGMedRes", 146 | "resJPEGThumbWidth", 147 | "resJPEGThumbHeight", 148 | "resJPEGThumbFileType", 149 | "resJPEGThumbFingerprint", 150 | "resJPEGThumbRes", 151 | "resVidFullWidth", 152 | "resVidFullHeight", 153 | "resVidFullFileType", 154 | "resVidFullFingerprint", 155 | "resVidFullRes", 156 | "resVidMedWidth", 157 | "resVidMedHeight", 158 | "resVidMedFileType", 159 | "resVidMedFingerprint", 160 | "resVidMedRes", 161 | "resVidSmallWidth", 162 | "resVidSmallHeight", 163 | "resVidSmallFileType", 164 | "resVidSmallFingerprint", 165 | "resVidSmallRes", 166 | "resSidecarWidth", 167 | "resSidecarHeight", 168 | "resSidecarFileType", 169 | "resSidecarFingerprint", 170 | "resSidecarRes", 171 | "itemType", 172 | "dataClassType", 173 | "filenameEnc", 174 | "originalOrientation", 175 | "resOriginalWidth", 176 | "resOriginalHeight", 177 | "resOriginalFileType", 178 | "resOriginalFingerprint", 179 | "resOriginalRes", 180 | "resOriginalAltWidth", 181 | "resOriginalAltHeight", 182 | "resOriginalAltFileType", 183 | "resOriginalAltFingerprint", 184 | "resOriginalAltRes", 185 | "resOriginalVidComplWidth", 186 | "resOriginalVidComplHeight", 187 | "resOriginalVidComplFileType", 188 | "resOriginalVidComplFingerprint", 189 | "resOriginalVidComplRes", 190 | "isDeleted", 191 | "isExpunged", 192 | "dateExpunged", 193 | "remappedRef", 194 | "recordName", 195 | "recordType", 196 | "recordChangeTag", 197 | "masterRef", 198 | "adjustmentRenderType", 199 | "assetDate", 200 | "addedDate", 201 | "isFavorite", 202 | "isHidden", 203 | "orientation", 204 | "duration", 205 | "assetSubtype", 206 | "assetSubtypeV2", 207 | "assetHDRType", 208 | "burstFlags", 209 | "burstFlagsExt", 210 | "burstId", 211 | "captionEnc", 212 | "locationEnc", 213 | "locationV2Enc", 214 | "locationLatitude", 215 | "locationLongitude", 216 | "adjustmentType", 217 | "timeZoneOffset", 218 | "vidComplDurValue", 219 | "vidComplDurScale", 220 | "vidComplDispValue", 221 | "vidComplDispScale", 222 | "vidComplVisibilityState", 223 | "customRenderedValue", 224 | "containerId", 225 | "itemId", 226 | "position", 227 | "isKeyAsset", 228 | }, 229 | "zoneID": map[string]any{"zoneName": "PrimarySync"}, 230 | } 231 | 232 | return res 233 | } 234 | 235 | type getPhotosResp struct { 236 | Records []*photoRecord `json:"records"` 237 | ContinuationMarker string `json:"continuationMarker"` 238 | SyncToken string `json:"syncToken"` 239 | } 240 | 241 | type photoRecord struct { 242 | RecordName string `json:"recordName"` 243 | RecordType string `json:"recordType"` 244 | Fields struct { 245 | ItemType strValue `json:"itemType,omitempty"` 246 | ResJPEGThumbFingerprint strValue `json:"resJPEGThumbFingerprint,omitempty"` 247 | FilenameEnc strValue `json:"filenameEnc,omitempty"` 248 | ResJPEGMedRes urlValue `json:"resJPEGMedRes,omitempty"` 249 | OriginalOrientation intValue `json:"originalOrientation,omitempty"` 250 | ResJPEGMedHeight intValue `json:"resJPEGMedHeight,omitempty"` 251 | ResOriginalRes urlValue `json:"resOriginalRes,omitempty"` 252 | ResJPEGMedFileType strValue `json:"resJPEGMedFileType,omitempty"` 253 | ResJPEGThumbHeight intValue `json:"resJPEGThumbHeight,omitempty"` 254 | ResJPEGThumbWidth intValue `json:"resJPEGThumbWidth,omitempty"` 255 | ResOriginalWidth intValue `json:"resOriginalWidth,omitempty"` 256 | ResJPEGThumbFileType strValue `json:"resJPEGThumbFileType,omitempty"` 257 | DataClassType intValue `json:"dataClassType,omitempty"` 258 | ResOriginalFingerprint strValue `json:"resOriginalFingerprint,omitempty"` 259 | ResJPEGMedWidth intValue `json:"resJPEGMedWidth,omitempty"` 260 | ResJPEGThumbRes urlValue `json:"resJPEGThumbRes,omitempty"` 261 | ResOriginalFileType strValue `json:"resOriginalFileType,omitempty"` 262 | ResOriginalHeight intValue `json:"resOriginalHeight,omitempty"` 263 | ResJPEGMedFingerprint strValue `json:"resJPEGMedFingerprint,omitempty"` 264 | ResVidSmallHeight intValue `json:"resVidSmallHeight,omitempty"` 265 | ResOriginalVidComplFileType strValue `json:"resOriginalVidComplFileType,omitempty"` 266 | ResOriginalVidComplWidth intValue `json:"resOriginalVidComplWidth,omitempty"` 267 | ResVidMedFileType strValue `json:"resVidMedFileType,omitempty"` 268 | ResVidMedRes urlValue `json:"resVidMedRes,omitempty"` 269 | ResVidSmallFingerprint strValue `json:"resVidSmallFingerprint,omitempty"` 270 | ResVidMedWidth intValue `json:"resVidMedWidth,omitempty"` 271 | ResOriginalVidComplFingerprint strValue `json:"resOriginalVidComplFingerprint,omitempty"` 272 | ResVidSmallFileType strValue `json:"resVidSmallFileType,omitempty"` 273 | ResVidSmallRes urlValue `json:"resVidSmallRes,omitempty"` 274 | ResOriginalVidComplRes urlValue `json:"resOriginalVidComplRes,omitempty"` 275 | ResVidMedFingerprint strValue `json:"resVidMedFingerprint,omitempty"` 276 | ResVidMedHeight intValue `json:"resVidMedHeight,omitempty"` 277 | ResOriginalVidComplHeight intValue `json:"resOriginalVidComplHeight,omitempty"` 278 | ResVidSmallWidth intValue `json:"resVidSmallWidth,omitempty"` 279 | AssetDate intValue `json:"assetDate,omitempty"` 280 | Orientation intValue `json:"orientation,omitempty"` 281 | AddedDate intValue `json:"addedDate,omitempty"` 282 | AssetSubtypeV2 intValue `json:"assetSubtypeV2,omitempty"` 283 | AssetHDRType intValue `json:"assetHDRType,omitempty"` 284 | TimeZoneOffset intValue `json:"timeZoneOffset,omitempty"` 285 | MasterRef struct { 286 | Value struct { 287 | RecordName string `json:"recordName"` 288 | Action string `json:"action"` 289 | ZoneID zoneValue `json:"zoneID"` 290 | } `json:"value"` 291 | Type string `json:"type"` 292 | } `json:"masterRef,omitempty"` 293 | AdjustmentRenderType intValue `json:"adjustmentRenderType,omitempty"` 294 | VidComplDispScale intValue `json:"vidComplDispScale,omitempty"` 295 | IsHidden intValue `json:"isHidden,omitempty"` 296 | Duration intValue `json:"duration,omitempty"` 297 | BurstFlags intValue `json:"burstFlags,omitempty"` 298 | AssetSubtype intValue `json:"assetSubtype,omitempty"` 299 | VidComplDurScale intValue `json:"vidComplDurScale,omitempty"` 300 | VidComplDurValue intValue `json:"vidComplDurValue,omitempty"` 301 | VidComplVisibilityState intValue `json:"vidComplVisibilityState,omitempty"` 302 | CustomRenderedValue intValue `json:"customRenderedValue,omitempty"` 303 | IsFavorite intValue `json:"isFavorite,omitempty"` 304 | VidComplDispValue intValue `json:"vidComplDispValue,omitempty"` 305 | LocationEnc strValue `json:"locationEnc,omitempty"` 306 | } `json:"fields"` 307 | PluginFields struct{} `json:"pluginFields"` 308 | RecordChangeTag string `json:"recordChangeTag"` 309 | Created timestampValue `json:"created"` 310 | Modified timestampValue `json:"modified"` 311 | Deleted bool `json:"deleted"` 312 | ZoneID zoneValue `json:"zoneID"` 313 | } 314 | 315 | type intValue struct { 316 | Value int64 `json:"value"` 317 | Type string `json:"type"` // INT64, TIMESTAMP, 318 | } 319 | 320 | type strValue struct { 321 | Value string `json:"value"` 322 | Type string `json:"type"` 323 | } 324 | 325 | type urlValue struct { 326 | Value urlValueVal `json:"value"` 327 | Type string `json:"type"` 328 | } 329 | 330 | type timestampValue struct { 331 | Timestamp int64 `json:"timestamp"` 332 | UserRecordName string `json:"userRecordName"` 333 | DeviceID string `json:"deviceID"` 334 | } 335 | 336 | type urlValueVal struct { 337 | FileChecksum string `json:"fileChecksum"` 338 | Size int `json:"size"` 339 | WrappingKey string `json:"wrappingKey"` 340 | ReferenceChecksum string `json:"referenceChecksum"` 341 | DownloadURL string `json:"downloadURL"` 342 | } 343 | 344 | type zoneValue struct { 345 | ZoneName string `json:"zoneName"` 346 | OwnerRecordName string `json:"ownerRecordName"` 347 | ZoneType string `json:"zoneType"` 348 | } 349 | -------------------------------------------------------------------------------- /internal/photo_album_photos_iter.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | type PhotosIterNext interface { 8 | Next() (*PhotoAsset, error) 9 | Offset() int64 10 | } 11 | 12 | func newPhotosIterNext(album *PhotoAlbum, offset int64) PhotosIterNext { 13 | return &photosIterNextImpl{ 14 | album: album, 15 | lock: new(sync.Mutex), 16 | offset: offset, 17 | assets: nil, 18 | index: 0, 19 | end: false, 20 | } 21 | } 22 | 23 | type photosIterNextImpl struct { 24 | album *PhotoAlbum 25 | lock *sync.Mutex 26 | offset int64 27 | assets []*PhotoAsset 28 | index int 29 | end bool 30 | } 31 | 32 | func (r *photosIterNextImpl) Next() (*PhotoAsset, error) { 33 | r.lock.Lock() 34 | defer r.lock.Unlock() 35 | 36 | if r.index < len(r.assets) { 37 | r.index++ 38 | return r.assets[r.index-1], nil 39 | } 40 | 41 | if r.end { 42 | return nil, ErrPhotosIterateEnd 43 | } 44 | 45 | assets, err := r.album.GetPhotosByOffset(r.offset, 200) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | r.index = 1 51 | r.assets = assets 52 | r.offset = r.album.calOffset(r.offset, int64(len(assets))) 53 | r.end = len(assets) == 0 54 | 55 | if r.end { 56 | return nil, ErrPhotosIterateEnd 57 | } 58 | 59 | return r.assets[r.index-1], nil 60 | } 61 | 62 | func (r *photosIterNextImpl) Offset() int64 { 63 | return r.offset 64 | } 65 | -------------------------------------------------------------------------------- /internal/photo_album_size.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | func (r *PhotoAlbum) Size() int64 { 10 | size, _ := r.GetSize() 11 | return size 12 | } 13 | 14 | func (r *PhotoAlbum) GetSize() (int64, error) { 15 | r.lock.Lock() 16 | defer r.lock.Unlock() 17 | 18 | if r._size != nil { 19 | return *r._size, nil 20 | } 21 | 22 | size, err := r.getSize() 23 | if err != nil { 24 | return 0, err 25 | } 26 | 27 | r._size = &size 28 | return size, nil 29 | } 30 | 31 | func (r *PhotoAlbum) getSize() (int64, error) { 32 | text, err := r.service.icloud.request(&rawReq{ 33 | Method: http.MethodPost, 34 | URL: fmt.Sprintf("%s/internal/records/query/batch", r.service.serviceEndpoint), 35 | Querys: r.service.querys, 36 | Headers: r.service.icloud.getCommonHeaders(map[string]string{}), 37 | Body: map[string]any{ 38 | "batch": []any{ 39 | map[string]any{ 40 | "resultsLimit": 1, 41 | "query": map[string]any{ 42 | "filterBy": []any{ 43 | map[string]any{ 44 | "fieldName": "indexCountID", 45 | "fieldValue": map[string]any{"type": "STRING_LIST", "value": []string{r.ObjType}}, 46 | "comparator": "IN", 47 | }, 48 | }, 49 | "recordType": "HyperionIndexCountLookup", 50 | }, 51 | "zoneWide": true, 52 | "zoneID": map[string]string{"zoneName": "PrimarySync"}, 53 | }, 54 | }, 55 | }, 56 | }) 57 | if err != nil { 58 | return 0, fmt.Errorf("get album size failed, err: %w", err) 59 | } 60 | res := new(getAlbumSizeResp) 61 | if err = json.Unmarshal([]byte(text), res); err != nil { 62 | return 0, fmt.Errorf("get album size unmarshal failed, err: %w, text: %s", err, text) 63 | } else if len(res.Batch) == 0 { 64 | return 0, fmt.Errorf("get album size failed, err: no batch response") 65 | } else if len(res.Batch[0].Records) == 0 { 66 | return 0, fmt.Errorf("get album size failed, err: no batch records response") 67 | } 68 | 69 | return res.Batch[0].Records[0].Fields.ItemCount.Value, nil 70 | } 71 | 72 | type getAlbumSizeResp struct { 73 | Batch []struct { 74 | Records []struct { 75 | RecordName string `json:"recordName"` 76 | RecordType string `json:"recordType"` 77 | Fields struct { 78 | ItemCount intValue `json:"itemCount"` 79 | } `json:"fields"` 80 | PluginFields struct{} `json:"pluginFields"` 81 | RecordChangeTag string `json:"recordChangeTag"` 82 | Created timestampValue `json:"created"` 83 | Modified timestampValue `json:"modified"` 84 | Deleted bool `json:"deleted"` 85 | ZoneID zoneValue `json:"zoneID"` 86 | } `json:"records"` 87 | } `json:"batch"` 88 | } 89 | -------------------------------------------------------------------------------- /internal/photo_asset.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "path/filepath" 8 | "strings" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | type PhotoAsset struct { 14 | service *PhotoService 15 | normalPhotos map[PhotoVersion]*photoVersionDetail 16 | livePhotoVideos map[PhotoVersion]*photoVersionDetail 17 | _masterRecord *photoRecord 18 | _assetRecord *photoRecord 19 | lock *sync.Mutex 20 | } 21 | 22 | func (r *PhotoAsset) Bytes() []byte { 23 | bs, _ := json.Marshal(photoAssetData{ 24 | MasterRecord: r._masterRecord, 25 | AssetRecord: r._assetRecord, 26 | }) 27 | return bs 28 | } 29 | 30 | func (r *PhotoService) NewPhotoAssetFromBytes(bs []byte) *PhotoAsset { 31 | data := &photoAssetData{} 32 | _ = json.Unmarshal(bs, data) 33 | 34 | return r.newPhotoAsset(data.MasterRecord, data.AssetRecord) 35 | } 36 | 37 | type photoAssetData struct { 38 | MasterRecord *photoRecord `json:"master_record"` 39 | AssetRecord *photoRecord `json:"asset_record"` 40 | } 41 | 42 | func (r *PhotoService) newPhotoAsset(masterRecord, assetRecords *photoRecord) *PhotoAsset { 43 | return &PhotoAsset{ 44 | service: r, 45 | normalPhotos: nil, 46 | livePhotoVideos: nil, 47 | _masterRecord: masterRecord, 48 | _assetRecord: assetRecords, 49 | lock: new(sync.Mutex), 50 | } 51 | } 52 | 53 | func (r *PhotoAsset) Filename(livePhoto bool) string { 54 | name := r.filename() 55 | if !livePhoto { 56 | return name 57 | } 58 | l := strings.SplitN(name, ".", 2) 59 | if len(l) == 2 { 60 | return l[0] + ".MOV" 61 | } 62 | return name + ".MOV" 63 | } 64 | 65 | func (r *PhotoAsset) filename() string { 66 | if v := r._masterRecord.Fields.FilenameEnc.Value; v != "" { 67 | bs, _ := base64.StdEncoding.DecodeString(v) 68 | if len(bs) > 0 { 69 | return cleanFilename(string(bs)) 70 | } 71 | } 72 | 73 | return cleanFilename(r.ID()) 74 | } 75 | 76 | func (r *PhotoAsset) LocalPath(outputDir string, size PhotoVersion, fileStructure string, livePhoto bool) string { 77 | filename := r.Filename(livePhoto) 78 | ext := filepath.Ext(filename) 79 | name := "" 80 | switch fileStructure { 81 | case "name": 82 | name = cleanFilename(filename) 83 | default: 84 | name = cleanFilename(r.ID()) 85 | } 86 | 87 | if size == PhotoVersionOriginal || size == "" { 88 | return filepath.Join(outputDir, name+ext) 89 | } 90 | 91 | return filepath.Join(outputDir, name+"_"+string(size)+ext) 92 | } 93 | 94 | func (r *PhotoAsset) ID() string { 95 | return r._masterRecord.RecordName 96 | } 97 | 98 | func (r *PhotoAsset) Size() int { 99 | return r._masterRecord.Fields.ResOriginalRes.Value.Size 100 | } 101 | 102 | func (r *PhotoAsset) FormatSize() string { 103 | return formatSize(r.Size()) 104 | } 105 | 106 | func (r *PhotoAsset) AddDate() time.Time { 107 | return time.UnixMilli(r._masterRecord.Created.Timestamp) 108 | } 109 | 110 | func (r *PhotoAsset) AssetDate() time.Time { 111 | return time.UnixMilli(r._assetRecord.Fields.AssetDate.Value) 112 | } 113 | 114 | func (r *PhotoAsset) OutputDir(output, folderStructure string) string { 115 | if folderStructure == "" || folderStructure == "/" { 116 | return output 117 | } 118 | 119 | assetDate := r.AssetDate().Format(folderStructure) 120 | return filepath.Join(output, assetDate) 121 | } 122 | 123 | // 仅为兼容性 124 | func (r *PhotoAsset) OldOutputDir(output, folderStructure string) string { 125 | if folderStructure == "" || folderStructure == "/" { 126 | return output 127 | } 128 | 129 | assetDate := r.AddDate().Format(folderStructure) 130 | return filepath.Join(output, assetDate) 131 | } 132 | 133 | func formatSize(size int) string { 134 | if size < 1024 { 135 | return fmt.Sprintf("%dB", size) 136 | } else if size < 1024*1024 { 137 | return fmt.Sprintf("%.2fKB", float64(size)/1024) 138 | } else if size < 1024*1024*1024 { 139 | return fmt.Sprintf("%.2fMB", float64(size)/1024/1024) 140 | } else { 141 | return fmt.Sprintf("%.2fGB", float64(size)/1024/1024/1024) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /internal/photo_asset_delete.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func (r *PhotoAsset) Delete() error { 9 | body := fmt.Sprintf(`{"operations":[{"operationType":"update","record":{"recordName":"%s","recordType":"%s","recordChangeTag":"%s","fields":{"isDeleted":{"value":1}}}}],"zoneID":{"zoneName":"PrimarySync"},"atomic":true}`, 10 | r._assetRecord.RecordName, 11 | r._assetRecord.RecordType, 12 | r._masterRecord.RecordChangeTag, 13 | ) 14 | _, err := r.service.icloud.request(&rawReq{ 15 | Method: http.MethodPost, 16 | URL: fmt.Sprintf("%s/records/modify", r.service.serviceEndpoint), 17 | Querys: r.service.querys, 18 | Headers: r.service.icloud.getCommonHeaders(map[string]string{}), 19 | Body: body, 20 | }) 21 | if err != nil { 22 | return fmt.Errorf("delete %s failed: %w", r.Filename(false), err) 23 | } 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/photo_asset_download.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | type PhotoVersion string 13 | 14 | const ( 15 | PhotoVersionOriginal PhotoVersion = "original" 16 | PhotoVersionMedium PhotoVersion = "medium" 17 | PhotoVersionThumb PhotoVersion = "thumb" 18 | ) 19 | 20 | func (r *PhotoAsset) DownloadTo(version PhotoVersion, livePhoto bool, target string) error { 21 | body, err := r.Download(version, livePhoto) 22 | if body != nil { 23 | defer body.Close() 24 | } 25 | if err != nil { 26 | return err 27 | } 28 | 29 | f, err := os.OpenFile(target, os.O_RDWR|os.O_CREATE, 0o644) 30 | if f != nil { 31 | defer f.Close() 32 | } 33 | if err != nil { 34 | return fmt.Errorf("open file error: %v", err) 35 | } 36 | 37 | _, err = io.Copy(f, body) 38 | if err != nil { 39 | return fmt.Errorf("copy file error: %v", err) 40 | } 41 | 42 | // 1676381385791 to time.time 43 | assetDate := r.AssetDate() 44 | if err := os.Chtimes(target, assetDate, assetDate); err != nil { 45 | return fmt.Errorf("change file time error: %v", err) 46 | } 47 | 48 | return nil 49 | } 50 | 51 | func (r *PhotoAsset) Download(version PhotoVersion, livePhoto bool) (io.ReadCloser, error) { 52 | versionDetail, ok := r.getVersions(livePhoto)[version] 53 | if !ok { 54 | var keys []string 55 | for k := range r.getVersions(livePhoto) { 56 | keys = append(keys, string(k)) 57 | } 58 | return nil, fmt.Errorf("version %s not found, valid: %s", version, strings.Join(keys, ",")) 59 | } 60 | 61 | timeout := time.Minute * 10 // 10分钟 62 | if versionDetail.Size > 0 { 63 | slowSecond := time.Duration(versionDetail.Size/1024/100) * time.Second // 100 KB/s 秒 64 | if slowSecond > timeout { 65 | timeout = slowSecond 66 | } 67 | } 68 | 69 | body, err := r.service.icloud.requestStream(&rawReq{ 70 | Method: http.MethodGet, 71 | URL: versionDetail.URL, 72 | Headers: r.service.icloud.getCommonHeaders(map[string]string{}), 73 | ExpectStatus: newSet[int](http.StatusOK), 74 | Timeout: timeout, 75 | }) 76 | if err != nil { 77 | return body, fmt.Errorf("download %s(timeout: %s) failed: %w", r.Filename(livePhoto), timeout, err) 78 | } 79 | return body, nil 80 | } 81 | 82 | func (r *PhotoAsset) IsLivePhoto() bool { 83 | f := r._masterRecord.Fields 84 | return f.ResOriginalVidComplRes.Value.DownloadURL != "" && 85 | f.ResOriginalRes.Value.DownloadURL != "" 86 | } 87 | 88 | func (r *PhotoAsset) getVersions(livePhoto bool) map[PhotoVersion]*photoVersionDetail { 89 | r.lock.Lock() 90 | defer r.lock.Unlock() 91 | 92 | if len(r.normalPhotos) == 0 { 93 | r.normalPhotos, r.livePhotoVideos = r.packVersion() 94 | } 95 | if livePhoto { 96 | return r.livePhotoVideos 97 | } 98 | 99 | return r.normalPhotos 100 | } 101 | 102 | func (r *PhotoAsset) packVersion() (map[PhotoVersion]*photoVersionDetail, map[PhotoVersion]*photoVersionDetail) { 103 | fields := r._masterRecord.Fields 104 | 105 | normal := map[PhotoVersion]*photoVersionDetail{ 106 | PhotoVersionOriginal: { 107 | Filename: r.Filename(false), 108 | Width: fields.ResOriginalWidth.Value, 109 | Height: fields.ResOriginalHeight.Value, 110 | Size: fields.ResOriginalRes.Value.Size, 111 | URL: fields.ResOriginalRes.Value.DownloadURL, 112 | Type: fields.ResOriginalFileType.Value, 113 | }, 114 | PhotoVersionMedium: { 115 | Filename: r.Filename(false), 116 | Width: fields.ResJPEGMedWidth.Value, 117 | Height: fields.ResJPEGMedHeight.Value, 118 | Size: fields.ResJPEGMedRes.Value.Size, 119 | URL: fields.ResJPEGMedRes.Value.DownloadURL, 120 | Type: fields.ResJPEGMedFileType.Value, 121 | }, 122 | PhotoVersionThumb: { 123 | Filename: r.Filename(false), 124 | Width: fields.ResJPEGThumbWidth.Value, 125 | Height: fields.ResJPEGThumbHeight.Value, 126 | Size: fields.ResJPEGThumbRes.Value.Size, 127 | URL: fields.ResJPEGThumbRes.Value.DownloadURL, 128 | Type: fields.ResJPEGThumbFileType.Value, 129 | }, 130 | } 131 | livePhotoVideo := map[PhotoVersion]*photoVersionDetail{ 132 | PhotoVersionOriginal: { 133 | Filename: r.Filename(true), 134 | Width: fields.ResOriginalVidComplWidth.Value, 135 | Height: fields.ResOriginalVidComplHeight.Value, 136 | Size: fields.ResOriginalVidComplRes.Value.Size, 137 | URL: fields.ResOriginalVidComplRes.Value.DownloadURL, 138 | Type: fields.ResOriginalVidComplFileType.Value, 139 | }, 140 | PhotoVersionMedium: { 141 | Filename: r.Filename(true), 142 | Width: fields.ResVidMedWidth.Value, 143 | Height: fields.ResVidMedHeight.Value, 144 | Size: fields.ResVidMedRes.Value.Size, 145 | URL: fields.ResVidMedRes.Value.DownloadURL, 146 | Type: fields.ResVidMedFileType.Value, 147 | }, 148 | PhotoVersionThumb: { 149 | Filename: r.Filename(true), 150 | Width: fields.ResVidSmallWidth.Value, 151 | Height: fields.ResVidSmallHeight.Value, 152 | Size: fields.ResVidSmallRes.Value.Size, 153 | URL: fields.ResVidSmallRes.Value.DownloadURL, 154 | Type: fields.ResVidSmallFileType.Value, 155 | }, 156 | } 157 | 158 | return normal, livePhotoVideo 159 | } 160 | 161 | type photoVersionDetail struct { 162 | Filename string `json:"filename"` 163 | Width int64 `json:"width"` 164 | Height int64 `json:"height"` 165 | Size int `json:"size"` 166 | URL string `json:"url"` 167 | Type string `json:"type"` 168 | } 169 | -------------------------------------------------------------------------------- /internal/photo_asset_upload.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | func (r *PhotoService) Upload(filename string, file io.Reader) (bool, error) { 11 | webServiceURL, err := r.icloud.getWebServiceURL(serviceUploadImage) 12 | if err != nil { 13 | return false, err 14 | } 15 | 16 | resp := new(uploadPhotoResp) 17 | body, err := r.icloud.request(&rawReq{ 18 | Method: http.MethodPost, 19 | URL: webServiceURL + "/upload", 20 | Headers: r.icloud.getCommonHeaders(map[string]string{"Content-Type": "text/plain"}), 21 | Querys: map[string]string{"filename": filename}, 22 | Body: file, 23 | }) 24 | if err != nil { 25 | return false, fmt.Errorf("upload %s failed: %w", filename, err) 26 | } 27 | if err := json.Unmarshal([]byte(body), resp); err != nil { 28 | return false, fmt.Errorf("upload %s unmarshal failed: %w", filename, err) 29 | } 30 | return resp.IsDuplicate, nil 31 | } 32 | 33 | type uploadPhotoResp struct { 34 | IsDuplicate bool `json:"isDuplicate"` 35 | } 36 | -------------------------------------------------------------------------------- /internal/photo_cli.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | ) 7 | 8 | type PhotoService struct { 9 | icloud *Client 10 | serviceRoot string 11 | serviceEndpoint string 12 | querys map[string]string 13 | 14 | _albums map[string]*PhotoAlbum 15 | lock *sync.Mutex 16 | } 17 | 18 | func (r *Client) PhotoCli() (*PhotoService, error) { 19 | if r.photo == nil { 20 | ckDatabaseWS, err := r.getWebServiceURL(serviceDatabase) 21 | if err != nil { 22 | return nil, err 23 | } 24 | r.photo, err = newPhotoService(r, ckDatabaseWS) 25 | if err != nil { 26 | return nil, err 27 | } 28 | } 29 | return r.photo, nil 30 | } 31 | 32 | func newPhotoService(icloud *Client, serviceRoot string) (*PhotoService, error) { 33 | photoCli := &PhotoService{ 34 | icloud: icloud, 35 | serviceRoot: serviceRoot, 36 | serviceEndpoint: fmt.Sprintf("%s/database/1/com.apple.photos.cloud/production/private", serviceRoot), 37 | querys: map[string]string{"remapEnums": "true", "getCurrentSyncToken": "true"}, 38 | 39 | _albums: map[string]*PhotoAlbum{}, 40 | lock: new(sync.Mutex), 41 | } 42 | 43 | if err := photoCli.checkPhotoServiceState(); err != nil { 44 | return nil, err 45 | } 46 | 47 | return photoCli, nil 48 | } 49 | 50 | func (r *PhotoService) getQuerys(m map[string]string) map[string]string { 51 | querys := map[string]string{} 52 | for k, v := range r.querys { 53 | querys[k] = v 54 | } 55 | for k, v := range m { 56 | querys[k] = v 57 | } 58 | return querys 59 | } 60 | -------------------------------------------------------------------------------- /internal/photo_database.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | func (r *PhotoService) checkPhotoServiceState() error { 10 | text, err := r.icloud.request(&rawReq{ 11 | Method: http.MethodPost, 12 | URL: fmt.Sprintf("%s/records/query", r.serviceEndpoint), 13 | Body: `{"query":{"recordType":"CheckIndexingState"},"zoneID":{"zoneName":"PrimarySync"}}`, 14 | Headers: r.icloud.getCommonHeaders(map[string]string{"Content-type": "text/plain"}), 15 | Querys: r.querys, 16 | }) 17 | if err != nil { 18 | return fmt.Errorf("checkPhotoServiceState failed, err: %w", err) 19 | } 20 | res := new(getPhotoDatabaseResp) 21 | if err = json.Unmarshal([]byte(text), res); err != nil { 22 | return fmt.Errorf("checkPhotoServiceState unmarshal failed, err: %w, text: %s", err, text) 23 | } 24 | 25 | if len(res.Records) > 0 { 26 | if res.Records[0].Fields.State.Value != "FINISHED" { 27 | return fmt.Errorf("iCloud Photo Library not finished indexing. Please try again in a few minutes.") 28 | } 29 | } 30 | 31 | return nil 32 | } 33 | 34 | type getPhotoDatabaseResp struct { 35 | Records []*photoDatabaseRecord `json:"records"` 36 | SyncToken string `json:"syncToken"` 37 | } 38 | 39 | type photoDatabaseRecord struct { 40 | RecordName string `json:"recordName"` 41 | RecordType string `json:"recordType"` 42 | Fields struct { 43 | Progress struct { 44 | Value int `json:"value"` 45 | Type string `json:"type"` 46 | } `json:"progress"` 47 | State struct { 48 | Value string `json:"value"` 49 | Type string `json:"type"` 50 | } `json:"state"` 51 | } `json:"fields"` 52 | PluginFields struct{} `json:"pluginFields"` 53 | RecordChangeTag string `json:"recordChangeTag"` 54 | Created struct { 55 | Timestamp int64 `json:"timestamp"` 56 | UserRecordName string `json:"userRecordName"` 57 | DeviceID string `json:"deviceID"` 58 | } `json:"created"` 59 | Modified struct { 60 | Timestamp int64 `json:"timestamp"` 61 | UserRecordName string `json:"userRecordName"` 62 | DeviceID string `json:"deviceID"` 63 | } `json:"modified"` 64 | Deleted bool `json:"deleted"` 65 | ZoneID struct { 66 | ZoneName string `json:"zoneName"` 67 | OwnerRecordName string `json:"ownerRecordName"` 68 | ZoneType string `json:"zoneType"` 69 | } `json:"zoneID"` 70 | } 71 | -------------------------------------------------------------------------------- /internal/photo_folder.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | const ( 11 | DriveRootID = "FOLDER::com.apple.CloudDocs::root" 12 | DriveTrashID = "TRASH_ROOT" 13 | ) 14 | 15 | func (r *DriveService) Folders(driveID string) (int, []*DriveFolder, error) { 16 | return r.getDriveFolders(driveID) 17 | } 18 | 19 | func (r *DriveService) getDriveFolders(driveID string) (int, []*DriveFolder, error) { 20 | text, err := r.icloud.request(&rawReq{ 21 | Method: http.MethodPost, 22 | URL: r.serviceEndpoint + "/retrieveItemDetailsInFolders", 23 | Headers: r.icloud.getCommonHeaders(map[string]string{"Content-type": "text/plain"}), 24 | Body: fmt.Sprintf(`[{"drivewsid":"%s","partialData":false,"includeHierarchy":true}]`, driveID), 25 | }) 26 | if err != nil { 27 | return 0, nil, fmt.Errorf("getDriveFolders failed, err: %w", err) 28 | } 29 | 30 | var res []*getDriveFoldersResp 31 | if err = json.Unmarshal([]byte(text), &res); err != nil { 32 | return 0, nil, fmt.Errorf("getDriveFolders unmarshal failed, err: %w, text: %s", err, text) 33 | } 34 | if len(res) == 0 { 35 | return 0, nil, nil 36 | } 37 | return res[0].NumberOfItems, res[0].Items, nil 38 | } 39 | 40 | type DriveFolder struct { 41 | DateCreated time.Time `json:"dateCreated"` 42 | Drivewsid string `json:"drivewsid"` 43 | Docwsid string `json:"docwsid"` 44 | Zone string `json:"zone"` 45 | Name string `json:"name"` 46 | ParentId string `json:"parentId"` 47 | Etag string `json:"etag"` 48 | Type string `json:"type"` 49 | AssetQuota int `json:"assetQuota,omitempty"` 50 | FileCount int `json:"fileCount,omitempty"` 51 | ShareCount int `json:"shareCount,omitempty"` 52 | ShareAliasCount int `json:"shareAliasCount,omitempty"` 53 | DirectChildrenCount int `json:"directChildrenCount,omitempty"` 54 | MaxDepth string `json:"maxDepth,omitempty"` 55 | Icons []struct { 56 | Url string `json:"url"` 57 | Type string `json:"type"` 58 | Size int `json:"size"` 59 | } `json:"icons,omitempty"` 60 | SupportedExtensions []string `json:"supportedExtensions,omitempty"` 61 | SupportedTypes []string `json:"supportedTypes,omitempty"` 62 | Extension string `json:"extension,omitempty"` 63 | DateModified time.Time `json:"dateModified,omitempty"` 64 | DateChanged time.Time `json:"dateChanged,omitempty"` 65 | Size int `json:"size,omitempty"` 66 | } 67 | 68 | type getDriveFoldersResp struct { 69 | NumberOfItems int `json:"numberOfItems"` 70 | Items []*DriveFolder `json:"items"` 71 | } 72 | -------------------------------------------------------------------------------- /internal/photo_folder_create.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | uuid "github.com/satori/go.uuid" 9 | ) 10 | 11 | func (r *DriveService) CreateFolder(parentDriveID, name string) (*DriveFolder, error) { 12 | return r.createDriveFolder(parentDriveID, name) 13 | } 14 | 15 | func (r *DriveService) createDriveFolder(parentDriveID, name string) (*DriveFolder, error) { 16 | clientID := uuid.NewV4().String() 17 | text, err := r.icloud.request(&rawReq{ 18 | Method: http.MethodPost, 19 | URL: r.serviceEndpoint + "/createFolders", 20 | Headers: r.icloud.getCommonHeaders(map[string]string{"Content-type": "text/plain"}), 21 | Body: fmt.Sprintf(`{"destinationDrivewsId":"%s","folders":[{"clientId":"FOLDER::%s::%s","name":"%s"}]}`, parentDriveID, clientID, clientID, name), 22 | }) 23 | if err != nil { 24 | return nil, fmt.Errorf("createDriveFolder failed, err: %w", err) 25 | } 26 | 27 | res := new(createDriveFolderResp) 28 | if err = json.Unmarshal([]byte(text), res); err != nil { 29 | return nil, fmt.Errorf("createDriveFolder unmarshal failed, err: %w, text: %s", err, text) 30 | } 31 | if len(res.Folders) == 0 { 32 | return nil, fmt.Errorf("createDriveFolder failed, no folder response, text: %s", text) 33 | } 34 | return res.Folders[0], nil 35 | } 36 | 37 | type createDriveFolderResp struct { 38 | DestinationDrivewsId string `json:"destinationDrivewsId"` 39 | Folders []*DriveFolder `json:"folders"` 40 | } 41 | -------------------------------------------------------------------------------- /internal/photo_folders.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | func (r *PhotoService) getFolders() ([]*folderRecord, error) { 10 | text, err := r.icloud.request(&rawReq{ 11 | Method: http.MethodPost, 12 | URL: r.serviceEndpoint + "/records/query", 13 | Headers: r.icloud.getCommonHeaders(map[string]string{"Content-type": "text/plain"}), 14 | Body: `{"query":{"recordType":"CPLAlbumByPositionLive"},"zoneID":{"zoneName":"PrimarySync"}}`, 15 | }) 16 | if err != nil { 17 | return nil, fmt.Errorf("getFolders failed, err: %w", err) 18 | } 19 | 20 | res := new(getFoldersResp) 21 | if err = json.Unmarshal([]byte(text), res); err != nil { 22 | return nil, fmt.Errorf("getFolders unmarshal failed, err: %w, text: %s", err, text) 23 | } 24 | 25 | return res.Records, nil 26 | } 27 | 28 | type getFoldersResp struct { 29 | Records []*folderRecord `json:"records"` 30 | } 31 | 32 | type folderRecord struct { 33 | RecordName string `json:"recordName"` 34 | RecordType string `json:"recordType"` 35 | Fields struct { 36 | RecordModificationDate struct { 37 | Value int64 `json:"value"` 38 | Type string `json:"type"` 39 | } `json:"recordModificationDate"` 40 | SortAscending struct { 41 | Value int `json:"value"` 42 | Type string `json:"type"` 43 | } `json:"sortAscending"` 44 | SortType struct { 45 | Value int `json:"value"` 46 | Type string `json:"type"` 47 | } `json:"sortType"` 48 | AlbumType struct { 49 | Value int `json:"value"` 50 | Type string `json:"type"` 51 | } `json:"albumType"` 52 | Position struct { 53 | Value int `json:"value"` 54 | Type string `json:"type"` 55 | } `json:"position"` 56 | SortTypeExt struct { 57 | Value int `json:"value"` 58 | Type string `json:"type"` 59 | } `json:"sortTypeExt"` 60 | ImportedByBundleIdentifierEnc struct { 61 | Value string `json:"value"` 62 | Type string `json:"type"` 63 | } `json:"importedByBundleIdentifierEnc,omitempty"` 64 | AlbumNameEnc *folderTypeValue `json:"albumNameEnc,omitempty"` 65 | IsDeleted *folderTypeValue `json:"isDeleted,omitempty"` 66 | } `json:"fields"` 67 | PluginFields struct{} `json:"pluginFields"` 68 | RecordChangeTag string `json:"recordChangeTag"` 69 | Created struct { 70 | Timestamp int64 `json:"timestamp"` 71 | UserRecordName string `json:"userRecordName"` 72 | DeviceID string `json:"deviceID"` 73 | } `json:"created"` 74 | Modified struct { 75 | Timestamp int64 `json:"timestamp"` 76 | UserRecordName string `json:"userRecordName"` 77 | DeviceID string `json:"deviceID"` 78 | } `json:"modified"` 79 | Deleted bool `json:"deleted"` 80 | ZoneID struct { 81 | ZoneName string `json:"zoneName"` 82 | OwnerRecordName string `json:"ownerRecordName"` 83 | ZoneType string `json:"zoneType"` 84 | } `json:"zoneID"` 85 | } 86 | 87 | type folderTypeValue struct { 88 | Value any `json:"value"` 89 | Type string `json:"type"` 90 | } 91 | -------------------------------------------------------------------------------- /internal/request.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type rawReq struct { 11 | Method string 12 | URL string 13 | Headers map[string]string 14 | Querys map[string]string 15 | Body any 16 | ExpectStatus set[int] 17 | Stream bool 18 | Timeout time.Duration 19 | } 20 | 21 | func (r *Client) request(req *rawReq) (string, error) { 22 | text, _, err := r.doRequest(req) 23 | return text, err 24 | } 25 | 26 | func (r *Client) requestStream(req *rawReq) (io.ReadCloser, error) { 27 | req.Stream = true 28 | _, body, err := r.doRequest(req) 29 | return body, err 30 | } 31 | 32 | func (r *Client) doRequest(req *rawReq) (string, io.ReadCloser, error) { 33 | status := 0 34 | 35 | res := r.httpCli.New(req.Method, req.URL).WithURLCookie("https://icloud.com.cn") 36 | if len(req.Headers) > 0 { 37 | res = res.WithHeaders(req.Headers) 38 | } 39 | if len(req.Querys) > 0 { 40 | res = res.WithQuerys(req.Querys) 41 | } 42 | if req.Body != nil { 43 | if len(req.Headers) > 0 && req.Headers["Content-Type"] == "text/plain" { 44 | res = res.WithBody(req.Body) 45 | } else { 46 | res = res.WithJSON(req.Body) 47 | } 48 | } 49 | if req.Timeout > 0 { 50 | res = res.WithTimeout(req.Timeout) 51 | } 52 | 53 | resp, respErr := res.Response() 54 | if resp != nil { 55 | for k, callback := range contextHeader { 56 | if resp.Header.Get(k) != "" { 57 | callback(r.sessionData, resp.Header.Get(k)) 58 | } 59 | } 60 | } 61 | 62 | status = res.MustResponseStatus() 63 | if status == http.StatusGone { 64 | return "", nil, fmt.Errorf("%s %s failed, %w", req.Method, req.URL, ErrResourceGone) 65 | } 66 | 67 | if req.Stream { 68 | if respErr != nil { 69 | return "", nil, fmt.Errorf("%s %s failed, status %d, err: %s", req.Method, req.URL, status, respErr) 70 | } 71 | if req.ExpectStatus != nil && req.ExpectStatus.Len() > 0 && !req.ExpectStatus.Has(status) { 72 | return "", nil, fmt.Errorf("%s %s failed, expect status %v, but got %d", req.Method, req.URL, req.ExpectStatus.String(), status) 73 | } 74 | return "", resp.Body, nil 75 | } 76 | 77 | text, err := res.Text() 78 | if err != nil { 79 | return text, nil, fmt.Errorf("%s %s failed, status %d, err: %s, response text: %s", req.Method, req.URL, status, err, text) 80 | } 81 | 82 | if err := mayErr([]byte(text)); err != nil { 83 | return text, nil, fmt.Errorf("%s %s failed, status %d, err: %w", req.Method, req.URL, status, err) 84 | } 85 | 86 | if req.ExpectStatus != nil && req.ExpectStatus.Len() > 0 && !req.ExpectStatus.Has(status) { 87 | return text, nil, fmt.Errorf("%s %s failed, expect status %v, but got %d, response text: %s", req.Method, req.URL, req.ExpectStatus.String(), status, text) 88 | } 89 | 90 | return text, nil, err 91 | } 92 | 93 | func (r *Client) getAuthHeaders(overwrite map[string]string) map[string]string { // "Accept": "*/*", 94 | headers := map[string]string{ 95 | "Accept": "*/*", 96 | "Content-Type": "application/json", 97 | "X-Apple-OAuth-Client-Id": "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d", 98 | "X-Apple-OAuth-Client-Type": "firstPartyAuth", 99 | "X-Apple-OAuth-Redirect-URI": "https://www.icloud.com", 100 | "X-Apple-OAuth-Require-Grant-Code": "true", 101 | "X-Apple-OAuth-Response-Mode": "web_message", 102 | "X-Apple-OAuth-Response-Type": "code", 103 | "X-Apple-OAuth-State": r.clientID, 104 | "X-Apple-Widget-Key": "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d", 105 | 106 | "Origin": r.homeEndpoint, 107 | "Referer": fmt.Sprintf("%s/", r.homeEndpoint), 108 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", 109 | } 110 | for k, v := range overwrite { 111 | headers[k] = v 112 | } 113 | return headers 114 | } 115 | 116 | func (r *Client) getCommonHeaders(overwrite map[string]string) map[string]string { // "Accept": "*/*", 117 | headers := map[string]string{ 118 | "Origin": r.homeEndpoint, 119 | "Referer": fmt.Sprintf("%s/", r.homeEndpoint), 120 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", 121 | } 122 | for k, v := range overwrite { 123 | headers[k] = v 124 | } 125 | return headers 126 | } 127 | 128 | var contextHeader = map[string]func(d *SessionData, v string){ 129 | "X-Apple-ID-Account-Country": func(d *SessionData, v string) { 130 | d.AccountCountry = v 131 | }, 132 | "X-Apple-ID-Session-Id": func(d *SessionData, v string) { 133 | d.SessionID = v 134 | }, 135 | "X-Apple-Session-Token": func(d *SessionData, v string) { 136 | d.SessionToken = v 137 | }, 138 | "X-Apple-TwoSV-Trust-Token": func(d *SessionData, v string) { 139 | d.TrustToken = v 140 | }, 141 | "scnt": func(d *SessionData, v string) { 142 | d.Scnt = v 143 | }, 144 | } 145 | -------------------------------------------------------------------------------- /internal/string_input.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "fmt" 4 | 5 | type StdinTextGetter struct { 6 | Tip string 7 | DefaultValue string 8 | } 9 | 10 | func (r *StdinTextGetter) GetText(tip string) (string, error) { 11 | if r.DefaultValue != "" { 12 | return r.DefaultValue, nil 13 | } 14 | if tip == "" { 15 | tip = r.Tip 16 | } 17 | fmt.Println("Please input", tip) 18 | var s string 19 | _, err := fmt.Scanln(&s) 20 | return s, err 21 | } 22 | -------------------------------------------------------------------------------- /internal/type.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | type User struct { 4 | AccountName string `json:"accountName"` 5 | Password string `json:"password"` 6 | } 7 | 8 | type SessionData struct { 9 | SessionToken string `json:"session_token"` 10 | Scnt string `json:"scnt"` 11 | SessionID string `json:"session_id"` 12 | AccountCountry string `json:"account_country"` 13 | TrustToken string `json:"trust_token"` 14 | } 15 | 16 | type ValidateData struct { 17 | DsInfo *ValidateDataDsInfo `json:"dsInfo"` 18 | HasMinimumDeviceForPhotosWeb bool `json:"hasMinimumDeviceForPhotosWeb"` 19 | ICDPEnabled bool `json:"iCDPEnabled"` 20 | Webservices map[string]*webService `json:"webservices"` 21 | PcsEnabled bool `json:"pcsEnabled"` 22 | TermsUpdateNeeded bool `json:"termsUpdateNeeded"` 23 | ConfigBag struct { 24 | Urls struct { 25 | AccountCreateUI string `json:"accountCreateUI"` 26 | AccountLoginUI string `json:"accountLoginUI"` 27 | AccountLogin string `json:"accountLogin"` 28 | AccountRepairUI string `json:"accountRepairUI"` 29 | DownloadICloudTerms string `json:"downloadICloudTerms"` 30 | RepairDone string `json:"repairDone"` 31 | AccountAuthorizeUI string `json:"accountAuthorizeUI"` 32 | VettingUrlForEmail string `json:"vettingUrlForEmail"` 33 | AccountCreate string `json:"accountCreate"` 34 | GetICloudTerms string `json:"getICloudTerms"` 35 | VettingUrlForPhone string `json:"vettingUrlForPhone"` 36 | } `json:"urls"` 37 | AccountCreateEnabled bool `json:"accountCreateEnabled"` 38 | } `json:"configBag"` 39 | HsaTrustedBrowser bool `json:"hsaTrustedBrowser"` 40 | AppsOrder []string `json:"appsOrder"` 41 | Version int `json:"version"` 42 | IsExtendedLogin bool `json:"isExtendedLogin"` 43 | PcsServiceIdentitiesIncluded bool `json:"pcsServiceIdentitiesIncluded"` 44 | IsRepairNeeded bool `json:"isRepairNeeded"` 45 | HsaChallengeRequired bool `json:"hsaChallengeRequired"` 46 | RequestInfo struct { 47 | Country string `json:"country"` 48 | TimeZone string `json:"timeZone"` 49 | Region string `json:"region"` 50 | } `json:"requestInfo"` 51 | PcsDeleted bool `json:"pcsDeleted"` 52 | ICloudInfo struct { 53 | SafariBookmarksHasMigratedToCloudKit bool `json:"SafariBookmarksHasMigratedToCloudKit"` 54 | } `json:"iCloudInfo"` 55 | Apps map[string]*ValidateDataApp `json:"apps"` 56 | } 57 | 58 | type ValidateDataDsInfo struct { 59 | HsaVersion int `json:"hsaVersion"` 60 | LastName string `json:"lastName"` 61 | ICDPEnabled bool `json:"iCDPEnabled"` 62 | TantorMigrated bool `json:"tantorMigrated"` 63 | Dsid string `json:"dsid"` 64 | HsaEnabled bool `json:"hsaEnabled"` 65 | IsHideMyEmailSubscriptionActive bool `json:"isHideMyEmailSubscriptionActive"` 66 | IroncadeMigrated bool `json:"ironcadeMigrated"` 67 | Locale string `json:"locale"` 68 | BrZoneConsolidated bool `json:"brZoneConsolidated"` 69 | ICDRSCapableDeviceList string `json:"ICDRSCapableDeviceList"` 70 | IsManagedAppleID bool `json:"isManagedAppleID"` 71 | IsCustomDomainsFeatureAvailable bool `json:"isCustomDomainsFeatureAvailable"` 72 | IsHideMyEmailFeatureAvailable bool `json:"isHideMyEmailFeatureAvailable"` 73 | ContinueOnDeviceEligibleDeviceInfo []string `json:"ContinueOnDeviceEligibleDeviceInfo"` 74 | Gilligvited bool `json:"gilligvited"` 75 | AppleIdAliases []interface{} `json:"appleIdAliases"` 76 | UbiquityEOLEnabled bool `json:"ubiquityEOLEnabled"` 77 | IsPaidDeveloper bool `json:"isPaidDeveloper"` 78 | CountryCode string `json:"countryCode"` 79 | NotificationId string `json:"notificationId"` 80 | PrimaryEmailVerified bool `json:"primaryEmailVerified"` 81 | ADsID string `json:"aDsID"` 82 | Locked bool `json:"locked"` 83 | ICDRSCapableDeviceCount int `json:"ICDRSCapableDeviceCount"` 84 | HasICloudQualifyingDevice bool `json:"hasICloudQualifyingDevice"` 85 | PrimaryEmail string `json:"primaryEmail"` 86 | AppleIdEntries []struct { 87 | IsPrimary bool `json:"isPrimary"` 88 | Type string `json:"type"` 89 | Value string `json:"value"` 90 | } `json:"appleIdEntries"` 91 | GilliganEnabled bool `json:"gilligan-enabled"` 92 | IsWebAccessAllowed bool `json:"isWebAccessAllowed"` 93 | FullName string `json:"fullName"` 94 | MailFlags struct { 95 | IsThreadingAvailable bool `json:"isThreadingAvailable"` 96 | IsSearchV2Provisioned bool `json:"isSearchV2Provisioned"` 97 | SCKMail bool `json:"sCKMail"` 98 | IsMppSupportedInCurrentCountry bool `json:"isMppSupportedInCurrentCountry"` 99 | } `json:"mailFlags"` 100 | LanguageCode string `json:"languageCode"` 101 | AppleId string `json:"appleId"` 102 | HasUnreleasedOS bool `json:"hasUnreleasedOS"` 103 | AnalyticsOptInStatus bool `json:"analyticsOptInStatus"` 104 | FirstName string `json:"firstName"` 105 | ICloudAppleIdAlias string `json:"iCloudAppleIdAlias"` 106 | NotesMigrated bool `json:"notesMigrated"` 107 | BeneficiaryInfo struct { 108 | IsBeneficiary bool `json:"isBeneficiary"` 109 | } `json:"beneficiaryInfo"` 110 | HasPaymentInfo bool `json:"hasPaymentInfo"` 111 | PcsDelet bool `json:"pcsDelet"` 112 | AppleIdAlias string `json:"appleIdAlias"` 113 | BrMigrated bool `json:"brMigrated"` 114 | StatusCode int `json:"statusCode"` 115 | FamilyEligible bool `json:"familyEligible"` 116 | } 117 | 118 | type ValidateDataApp struct { 119 | CanLaunchWithOneFactor bool `json:"canLaunchWithOneFactor"` // Find 120 | IsQualifiedForBeta bool `json:"isQualifiedForBeta"` // Numbers 121 | } 122 | 123 | type webService struct { 124 | PcsRequired bool `json:"pcsRequired"` 125 | URL string `json:"url"` 126 | UploadURL string `json:"uploadUrl"` 127 | Status string `json:"status"` 128 | } 129 | -------------------------------------------------------------------------------- /internal/util.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | func setIfNotEmpty[T comparable](m map[string]T, key string, val T) map[string]T { 10 | var empty T 11 | if val != empty { 12 | m[key] = val 13 | } 14 | return m 15 | } 16 | 17 | var ( 18 | invalidChars = []rune{' ', '!', '@', '#', '$', '%', '^', '&', '(', ')', '+', '=', '[', ']', '{', '}', ';', ':', '\'', '"', ',', '.', '<', '>', '/', '?', '\\', '|'} 19 | invalidChar = map[rune]bool{} 20 | ) 21 | 22 | func init() { 23 | for _, v := range invalidChars { 24 | invalidChar[v] = true 25 | } 26 | } 27 | 28 | func cleanName(s string) string { 29 | l := []rune(s) 30 | for i, v := range l { 31 | if invalidChar[v] { 32 | l[i] = '_' 33 | } 34 | } 35 | return string(l) 36 | } 37 | 38 | func cleanFilename(s string) string { 39 | ext := filepath.Ext(s) 40 | base := s[:len(s)-len(ext)] 41 | return cleanName(base) + ext 42 | } 43 | 44 | type set[T comparable] map[T]struct{} 45 | 46 | func newSet[T comparable](initValue ...T) set[T] { 47 | res := make(map[T]struct{}) 48 | for _, v := range initValue { 49 | res[v] = struct{}{} 50 | } 51 | return res 52 | } 53 | 54 | func (s set[T]) Add(item T) { 55 | s[item] = struct{}{} 56 | } 57 | 58 | func (s set[T]) Remove(item T) { 59 | delete(s, item) 60 | } 61 | 62 | func (s set[T]) Len() int { 63 | return len(s) 64 | } 65 | 66 | func (s set[T]) Has(item T) bool { 67 | _, ok := s[item] 68 | return ok 69 | } 70 | 71 | func (s set[T]) String() string { 72 | var res []string 73 | for k := range s { 74 | res = append(res, fmt.Sprintf("%v", k)) 75 | } 76 | return strings.Join(res, ",") 77 | } 78 | --------------------------------------------------------------------------------