diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fdfe4e7..bd6b591 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,25 +1,30 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/dotnet { - "name": "C# (.NET)", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0-bookworm", - // Features to add to the dev container. More info: https://containers.dev/features. - "features": { - "ghcr.io/devcontainers-contrib/features/pre-commit:2": {} - }, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [5000, 5001], - // "portsAttributes": { - // "5001": { - // "protocol": "https" - // } - // } - // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "sh . ./.devcontainer/postCreateCommand.sh", - "postStartCommand": "sh . ./.devcontainer/postStartCommand.sh", - // Configure tool-specific properties. - // "customizations": {}, - // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. - "remoteUser": "vscode" + "name": "C# (.NET)", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0-bookworm", + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers-contrib/features/pre-commit:2": {}, + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [5000, 5001], + // "portsAttributes": { + // "5001": { + // "protocol": "https" + // } + // } + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "sh . ./.devcontainer/postCreateCommand.sh", + "postStartCommand": "sh . ./.devcontainer/postStartCommand.sh", + // Configure tool-specific properties. + // "customizations": {}, + "mounts": [ + "source=${localEnv:HOME}${localEnv:USERPROFILE}/source/github,target=/workspaces,type=bind,consistency=cached", + "source=${env:HOME}${env:USERPROFILE}/.kube,target=/home/vscode/.kube,type=bind" + ] + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" } diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ded57b3..24b9b71 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,5 +17,4 @@ updates: - dependency-name: CasCap.Common.* - dependency-name: coverlet.* - dependency-name: Microsoft.NET.Test.Sdk - - dependency-name: Newtonsoft.Json - dependency-name: xunit.* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a66efee..ca5cdae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,10 +30,40 @@ jobs: lint: uses: f2calv/gha-workflows/.github/workflows/lint.yml@v1 - ci: - uses: f2calv/gha-workflows/.github/workflows/dotnet-publish-nuget.yml@v1 + versioning: + uses: f2calv/gha-workflows/.github/workflows/gha-release-versioning.yml@v1 with: - configuration: ${{ github.event.inputs.configuration }} - push-preview: ${{ github.event.inputs.push-preview }} - secrets: - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + tag-prefix: '' + tag-and-release: false + + build: + runs-on: ubuntu-latest + needs: [versioning] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: f2calv/gha-dotnet-nuget@v2 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + configuration: ${{ inputs.configuration }} + push-preview: ${{ inputs.push-preview }} + version: ${{ needs.versioning.outputs.version }} + solution-name: CasCap.Api.GooglePhotos.Release.sln + execute-tests: false #Note: tests fail in Actions due to rate limiting issues from the Google side :/ + env: + GOOGLE_PHOTOS_ACCESS_TOKEN: ${{ secrets.GOOGLE_PHOTOS_ACCESS_TOKEN }} + + release: + needs: [versioning, build] + if: needs.versioning.outputs.release-exists == 'false' + && (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || inputs.push-preview == 'true') + uses: f2calv/gha-workflows/.github/workflows/gha-release-versioning.yml@v1 + permissions: + contents: write + with: + semVer: ${{ needs.versioning.outputs.version }} + tag-prefix: '' + move-major-tag: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9af01cc..a52219f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-xml - id: check-yaml @@ -16,7 +16,7 @@ repos: hooks: - id: check-json5 - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.41.0 + rev: v0.43.0 hooks: - id: markdownlint args: ["--disable", "MD013", "--disable", "MD034", "--"] diff --git a/CasCap.Api.GooglePhotos.Debug.sln b/CasCap.Api.GooglePhotos.Debug.sln new file mode 100644 index 0000000..d73842d --- /dev/null +++ b/CasCap.Api.GooglePhotos.Debug.sln @@ -0,0 +1,78 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35309.182 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CasCap.Api.GooglePhotos", "src\CasCap.Api.GooglePhotos\CasCap.Api.GooglePhotos.csproj", "{2A448BCC-84A1-4512-87B3-2685E40B75C4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CasCap.Api.GooglePhotos.Tests", "src\CasCap.Api.GooglePhotos.Tests\CasCap.Api.GooglePhotos.Tests.csproj", "{943A9741-D29D-455F-917F-95FF428F80FD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{1FAD3270-948C-415D-9D2C-63D410E92476}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenericHost", "samples\GenericHost\GenericHost.csproj", "{A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleApp", "samples\ConsoleApp\ConsoleApp.csproj", "{CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CasCap.Common.Testing", "..\CasCap.Common\src\CasCap.Common.Testing\CasCap.Common.Testing.csproj", "{09BB7730-C89F-4978-BA26-99B347ACA7A4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CasCap.Common.Net", "..\CasCap.Common\src\CasCap.Common.Net\CasCap.Common.Net.csproj", "{B7278559-B5D2-4481-8C12-4C9F2516341A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CasCap.Common", "CasCap.Common", "{4092EFD9-C0EE-4876-8CEE-AA1289C1AC36}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Debug|x64.ActiveCfg = Debug|Any CPU + {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Debug|x64.Build.0 = Debug|Any CPU + {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Debug|x86.Build.0 = Debug|Any CPU + {943A9741-D29D-455F-917F-95FF428F80FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {943A9741-D29D-455F-917F-95FF428F80FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {943A9741-D29D-455F-917F-95FF428F80FD}.Debug|x64.ActiveCfg = Debug|Any CPU + {943A9741-D29D-455F-917F-95FF428F80FD}.Debug|x64.Build.0 = Debug|Any CPU + {943A9741-D29D-455F-917F-95FF428F80FD}.Debug|x86.ActiveCfg = Debug|Any CPU + {943A9741-D29D-455F-917F-95FF428F80FD}.Debug|x86.Build.0 = Debug|Any CPU + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Debug|x64.ActiveCfg = Debug|Any CPU + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Debug|x64.Build.0 = Debug|Any CPU + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Debug|x86.ActiveCfg = Debug|Any CPU + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Debug|x86.Build.0 = Debug|Any CPU + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}.Debug|x64.ActiveCfg = Debug|Any CPU + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}.Debug|x64.Build.0 = Debug|Any CPU + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}.Debug|x86.ActiveCfg = Debug|Any CPU + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}.Debug|x86.Build.0 = Debug|Any CPU + {09BB7730-C89F-4978-BA26-99B347ACA7A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09BB7730-C89F-4978-BA26-99B347ACA7A4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09BB7730-C89F-4978-BA26-99B347ACA7A4}.Debug|x64.ActiveCfg = Debug|Any CPU + {09BB7730-C89F-4978-BA26-99B347ACA7A4}.Debug|x64.Build.0 = Debug|Any CPU + {09BB7730-C89F-4978-BA26-99B347ACA7A4}.Debug|x86.ActiveCfg = Debug|Any CPU + {09BB7730-C89F-4978-BA26-99B347ACA7A4}.Debug|x86.Build.0 = Debug|Any CPU + {B7278559-B5D2-4481-8C12-4C9F2516341A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7278559-B5D2-4481-8C12-4C9F2516341A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7278559-B5D2-4481-8C12-4C9F2516341A}.Debug|x64.ActiveCfg = Debug|Any CPU + {B7278559-B5D2-4481-8C12-4C9F2516341A}.Debug|x64.Build.0 = Debug|Any CPU + {B7278559-B5D2-4481-8C12-4C9F2516341A}.Debug|x86.ActiveCfg = Debug|Any CPU + {B7278559-B5D2-4481-8C12-4C9F2516341A}.Debug|x86.Build.0 = Debug|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5} = {1FAD3270-948C-415D-9D2C-63D410E92476} + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D} = {1FAD3270-948C-415D-9D2C-63D410E92476} + {09BB7730-C89F-4978-BA26-99B347ACA7A4} = {4092EFD9-C0EE-4876-8CEE-AA1289C1AC36} + {B7278559-B5D2-4481-8C12-4C9F2516341A} = {4092EFD9-C0EE-4876-8CEE-AA1289C1AC36} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {315434A5-DC61-4070-8452-C6695E1B14E0} + EndGlobalSection +EndGlobal diff --git a/CasCap.Api.GooglePhotos.Release.sln b/CasCap.Api.GooglePhotos.Release.sln new file mode 100644 index 0000000..6835f5c --- /dev/null +++ b/CasCap.Api.GooglePhotos.Release.sln @@ -0,0 +1,66 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35309.182 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CasCap.Api.GooglePhotos", "src\CasCap.Api.GooglePhotos\CasCap.Api.GooglePhotos.csproj", "{2A448BCC-84A1-4512-87B3-2685E40B75C4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CasCap.Api.GooglePhotos.Tests", "src\CasCap.Api.GooglePhotos.Tests\CasCap.Api.GooglePhotos.Tests.csproj", "{943A9741-D29D-455F-917F-95FF428F80FD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{1FAD3270-948C-415D-9D2C-63D410E92476}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenericHost", "samples\GenericHost\GenericHost.csproj", "{A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleApp", "samples\ConsoleApp\ConsoleApp.csproj", "{CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CasCap.Common", "CasCap.Common", "{4092EFD9-C0EE-4876-8CEE-AA1289C1AC36}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" + ProjectSection(SolutionItems) = preProject + Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Release|Any CPU.Build.0 = Release|Any CPU + {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Release|x64.ActiveCfg = Release|Any CPU + {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Release|x64.Build.0 = Release|Any CPU + {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Release|x86.ActiveCfg = Release|Any CPU + {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Release|x86.Build.0 = Release|Any CPU + {943A9741-D29D-455F-917F-95FF428F80FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {943A9741-D29D-455F-917F-95FF428F80FD}.Release|Any CPU.Build.0 = Release|Any CPU + {943A9741-D29D-455F-917F-95FF428F80FD}.Release|x64.ActiveCfg = Release|Any CPU + {943A9741-D29D-455F-917F-95FF428F80FD}.Release|x64.Build.0 = Release|Any CPU + {943A9741-D29D-455F-917F-95FF428F80FD}.Release|x86.ActiveCfg = Release|Any CPU + {943A9741-D29D-455F-917F-95FF428F80FD}.Release|x86.Build.0 = Release|Any CPU + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Release|Any CPU.Build.0 = Release|Any CPU + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Release|x64.ActiveCfg = Release|Any CPU + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Release|x64.Build.0 = Release|Any CPU + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Release|x86.ActiveCfg = Release|Any CPU + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Release|x86.Build.0 = Release|Any CPU + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}.Release|Any CPU.Build.0 = Release|Any CPU + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}.Release|x64.ActiveCfg = Release|Any CPU + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}.Release|x64.Build.0 = Release|Any CPU + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}.Release|x86.ActiveCfg = Release|Any CPU + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5} = {1FAD3270-948C-415D-9D2C-63D410E92476} + {CACAD45C-8DAF-4E61-B6F3-1C911656DB9D} = {1FAD3270-948C-415D-9D2C-63D410E92476} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {315434A5-DC61-4070-8452-C6695E1B14E0} + EndGlobalSection +EndGlobal diff --git a/CasCap.Apis.GooglePhotos.sln b/CasCap.Apis.GooglePhotos.sln deleted file mode 100644 index 74e727d..0000000 --- a/CasCap.Apis.GooglePhotos.sln +++ /dev/null @@ -1,83 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29009.5 -MinimumVisualStudioVersion = 15.0.26124.0 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CasCap.Apis.GooglePhotos", "src\CasCap.Apis.GooglePhotos\CasCap.Apis.GooglePhotos.csproj", "{2A448BCC-84A1-4512-87B3-2685E40B75C4}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CasCap.Apis.GooglePhotos.Tests", "src\CasCap.Apis.GooglePhotos.Tests\CasCap.Apis.GooglePhotos.Tests.csproj", "{943A9741-D29D-455F-917F-95FF428F80FD}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{1FAD3270-948C-415D-9D2C-63D410E92476}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleApp", "samples\ConsoleApp\ConsoleApp.csproj", "{2B5E1E09-BAAF-4D66-89BF-D80A1912B6CA}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenericHost", "samples\GenericHost\GenericHost.csproj", "{A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Debug|x64.ActiveCfg = Debug|Any CPU - {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Debug|x64.Build.0 = Debug|Any CPU - {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Debug|x86.ActiveCfg = Debug|Any CPU - {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Debug|x86.Build.0 = Debug|Any CPU - {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Release|Any CPU.Build.0 = Release|Any CPU - {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Release|x64.ActiveCfg = Release|Any CPU - {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Release|x64.Build.0 = Release|Any CPU - {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Release|x86.ActiveCfg = Release|Any CPU - {2A448BCC-84A1-4512-87B3-2685E40B75C4}.Release|x86.Build.0 = Release|Any CPU - {943A9741-D29D-455F-917F-95FF428F80FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {943A9741-D29D-455F-917F-95FF428F80FD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {943A9741-D29D-455F-917F-95FF428F80FD}.Debug|x64.ActiveCfg = Debug|Any CPU - {943A9741-D29D-455F-917F-95FF428F80FD}.Debug|x64.Build.0 = Debug|Any CPU - {943A9741-D29D-455F-917F-95FF428F80FD}.Debug|x86.ActiveCfg = Debug|Any CPU - {943A9741-D29D-455F-917F-95FF428F80FD}.Debug|x86.Build.0 = Debug|Any CPU - {943A9741-D29D-455F-917F-95FF428F80FD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {943A9741-D29D-455F-917F-95FF428F80FD}.Release|Any CPU.Build.0 = Release|Any CPU - {943A9741-D29D-455F-917F-95FF428F80FD}.Release|x64.ActiveCfg = Release|Any CPU - {943A9741-D29D-455F-917F-95FF428F80FD}.Release|x64.Build.0 = Release|Any CPU - {943A9741-D29D-455F-917F-95FF428F80FD}.Release|x86.ActiveCfg = Release|Any CPU - {943A9741-D29D-455F-917F-95FF428F80FD}.Release|x86.Build.0 = Release|Any CPU - {2B5E1E09-BAAF-4D66-89BF-D80A1912B6CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2B5E1E09-BAAF-4D66-89BF-D80A1912B6CA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2B5E1E09-BAAF-4D66-89BF-D80A1912B6CA}.Debug|x64.ActiveCfg = Debug|Any CPU - {2B5E1E09-BAAF-4D66-89BF-D80A1912B6CA}.Debug|x64.Build.0 = Debug|Any CPU - {2B5E1E09-BAAF-4D66-89BF-D80A1912B6CA}.Debug|x86.ActiveCfg = Debug|Any CPU - {2B5E1E09-BAAF-4D66-89BF-D80A1912B6CA}.Debug|x86.Build.0 = Debug|Any CPU - {2B5E1E09-BAAF-4D66-89BF-D80A1912B6CA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2B5E1E09-BAAF-4D66-89BF-D80A1912B6CA}.Release|x64.ActiveCfg = Release|Any CPU - {2B5E1E09-BAAF-4D66-89BF-D80A1912B6CA}.Release|x64.Build.0 = Release|Any CPU - {2B5E1E09-BAAF-4D66-89BF-D80A1912B6CA}.Release|x86.ActiveCfg = Release|Any CPU - {2B5E1E09-BAAF-4D66-89BF-D80A1912B6CA}.Release|x86.Build.0 = Release|Any CPU - {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Debug|x64.ActiveCfg = Debug|Any CPU - {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Debug|x64.Build.0 = Debug|Any CPU - {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Debug|x86.ActiveCfg = Debug|Any CPU - {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Debug|x86.Build.0 = Debug|Any CPU - {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Release|x64.ActiveCfg = Release|Any CPU - {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Release|x64.Build.0 = Release|Any CPU - {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Release|x86.ActiveCfg = Release|Any CPU - {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {2B5E1E09-BAAF-4D66-89BF-D80A1912B6CA} = {1FAD3270-948C-415D-9D2C-63D410E92476} - {A6C54B65-6A2D-4BA9-8A11-6097CC351DA5} = {1FAD3270-948C-415D-9D2C-63D410E92476} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {315434A5-DC61-4070-8452-C6695E1B14E0} - EndGlobalSection -EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props index 71d50b5..8431bf5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,11 +2,11 @@ CasCap - 12.0 + 13.0 + enable bf9d717e-ecd3-40e4-850d-14010c167289 - enable @@ -22,7 +22,7 @@ Alex Vincent true - https://github.com/f2calv/CasCap.Apis.GooglePhotos + https://github.com/f2calv/CasCap.Api.GooglePhotos MIT true snupkg diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..a98e2ac --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,30 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index c0a573e..97cb087 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# CasCap.Apis.GooglePhotos +# CasCap.Api.GooglePhotos ## _Unofficial_ Google Photos Library API wrapper library for .NET applications -[cascap.apis.googlephotos-badge]: https://img.shields.io/nuget/v/CasCap.Apis.GooglePhotos?color=blue -[cascap.apis.googlephotos-url]: https://nuget.org/packages/CasCap.Apis.GooglePhotos +[CasCap.Api.GooglePhotos-badge]: https://img.shields.io/nuget/v/CasCap.Api.GooglePhotos?color=blue +[CasCap.Api.GooglePhotos-url]: https://nuget.org/packages/CasCap.Api.GooglePhotos -![CI](https://github.com/f2calv/CasCap.Apis.GooglePhotos/actions/workflows/ci.yml/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/f2calv/CasCap.Apis.GooglePhotos/badge.svg?branch=main)](https://coveralls.io/github/f2calv/CasCap.Apis.GooglePhotos?branch=main) [![SonarCloud Coverage](https://sonarcloud.io/api/project_badges/measure?project=f2calv_CasCap.Apis.GooglePhotos&metric=code_smells)](https://sonarcloud.io/component_measures/metric/code_smells/list?id=f2calv_CasCap.Apis.GooglePhotos) [![Nuget][cascap.apis.googlephotos-badge]][cascap.apis.googlephotos-url] +![CI](https://github.com/f2calv/CasCap.Api.GooglePhotos/actions/workflows/ci.yml/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/f2calv/CasCap.Api.GooglePhotos/badge.svg?branch=main)](https://coveralls.io/github/f2calv/CasCap.Api.GooglePhotos?branch=main) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=f2calv_CasCap.Api.GooglePhotos&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=f2calv_CasCap.Api.GooglePhotos) [![Nuget][CasCap.Api.GooglePhotos-badge]][CasCap.Api.GooglePhotos-url] > Want to save yourself some coding? See the _preview_ release of [GooglePhotosCli](https://github.com/f2calv/CasCap.GooglePhotosCli) using this library... @@ -17,7 +17,7 @@ If you find this library of use then please give it a thumbs-up by giving this r If you wish to interact with your Google Photos media items/albums then there are official [PHP and Java Client Libraries](https://developers.google.com/photos/library/guides/client-libraries). However if you're looking for a comprehensive .NET library then you were out of luck... until now :) -The _CasCap.Apis.GooglePhotos_ library wraps up all the available functionality of the Google Photos REST API in easy to use methods. +The _CasCap.Api.GooglePhotos_ library wraps up all the available functionality of the Google Photos REST API in easy to use methods. Note: Before you jump in and use this library you should be aware that the [Google Photos Library API](https://developers.google.com/photos/library/reference/rest) has some key limitations. The biggest of these is that the API only allows the upload/addition of images/videos to the library, no edits or deletion are possible and have to be done manually via [https://photos.google.com](https://photos.google.com). @@ -40,7 +40,7 @@ Using your Google Account the steps are\*; ## Library Configuration/Usage -Install the package into your project using NuGet ([see details here](https://www.nuget.org/packages/CasCap.Apis.GooglePhotos/)). +Install the package into your project using NuGet ([see details here](https://www.nuget.org/packages/CasCap.Api.GooglePhotos/)). For .NET Core applications using dependency injection the primary API usage is to call IServiceCollection.AddGooglePhotos in the Startup.cs ConfigureServices method. @@ -255,9 +255,9 @@ public class Startup All API functions are exposed by the GooglePhotosService class. There are several sample .NET Core applications which show the basics on how to set-up/config/use the library; -- [Console App](https://github.com/f2calv/CasCap.Apis.GooglePhotos/tree/master/samples/ConsoleApp) with no dependency injection. -- [Console App](https://github.com/f2calv/CasCap.Apis.GooglePhotos/tree/master/samples/GenericHost) using configuration, logging and dependency injection via the [.NET Generic Host](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host?view=aspnetcore-3.1). -- [Integration Test App](https://github.com/f2calv/CasCap.Apis.GooglePhotos/blob/master/src/CasCap.Apis.GooglePhotos.Tests/Tests/Tests.cs) has the majority of the commented examples of various interactions. +- [Console App](https://github.com/f2calv/CasCap.Api.GooglePhotos/tree/master/samples/ConsoleApp) with no dependency injection. +- [Console App](https://github.com/f2calv/CasCap.Api.GooglePhotos/tree/master/samples/GenericHost) using configuration, logging and dependency injection via the [.NET Generic Host](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host?view=aspnetcore-3.1). +- [Integration Test App](https://github.com/f2calv/CasCap.Api.GooglePhotos/blob/master/src/CasCap.Api.GooglePhotos.Tests/Tests/Tests.cs) has the majority of the commented examples of various interactions. ### Core Dependencies @@ -267,20 +267,20 @@ All API functions are exposed by the GooglePhotosService class. There are severa ### Misc Tips -- The [NuGet package](https://www.nuget.org/packages/CasCap.Apis.GooglePhotos/) includes [SourceLink](https://github.com/dotnet/sourcelink) which enables you to jump inside the library and debug the API yourself. By default Visual Studio 2017/2019 does not allow this and will pop up an message "You are debugging a Release build of...", to disable this message go into the Visual Studio debugging options and un-check the 'Just My Code' option (menu path, Tools > Options > Debugging). +- The [NuGet package](https://www.nuget.org/packages/CasCap.Api.GooglePhotos/) includes [SourceLink](https://github.com/dotnet/sourcelink) which enables you to jump inside the library and debug the API yourself. By default Visual Studio 2017/2019 does not allow this and will pop up an message "You are debugging a Release build of...", to disable this message go into the Visual Studio debugging options and un-check the 'Just My Code' option (menu path, Tools > Options > Debugging). ### Resources -- https://developers.google.com/photos -- https://console.developers.google.com +- +- - [Google Photos Library API](https://developers.google.com/photos) - [Google Photos Library API REST Reference](https://developers.google.com/photos/library/reference/rest) - [Google Photos Library API Authorisation Scopes](https://developers.google.com/photos/library/guides/authorization) ### Feedback/Issues -Please post any issues or feedback [here](https://github.com/f2calv/CasCap.Apis.GooglePhotos/issues). +Please post any issues or feedback [here](https://github.com/f2calv/CasCap.Api.GooglePhotos/issues). ### License -CasCap.Apis.GooglePhotos is Copyright © 2020 [Alex Vincent](https://github.com/f2calv) under the [MIT license](LICENSE). +CasCap.Api.GooglePhotos is Copyright © 2020 [Alex Vincent](https://github.com/f2calv) under the [MIT license](LICENSE). diff --git a/samples/ConsoleApp/ConsoleApp.csproj b/samples/ConsoleApp/ConsoleApp.csproj index 7e3e51c..539c2c6 100644 --- a/samples/ConsoleApp/ConsoleApp.csproj +++ b/samples/ConsoleApp/ConsoleApp.csproj @@ -2,11 +2,11 @@ Exe - net8.0 + net8.0;net9.0 - + diff --git a/samples/ConsoleApp/Program.cs b/samples/ConsoleApp/Program.cs index 4bb4fa4..2a12117 100644 --- a/samples/ConsoleApp/Program.cs +++ b/samples/ConsoleApp/Program.cs @@ -1,16 +1,9 @@ -using CasCap.Models; -using CasCap.Services; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using System.Diagnostics; -using System.Net; - -string _user = null;//e.g. "your.email@mydomain.com"; +string _user = null;//e.g. "your.email@mydomain.com"; string _clientId = null;//e.g. "012345678901-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.apps.googleusercontent.com"; string _clientSecret = null;//e.g. "abcabcabcabcabcabcabcabc"; const string _testFolder = "c:/temp/GooglePhotos/";//local folder of test media files -if (new[] { _user, _clientId, _clientSecret }.Any(p => string.IsNullOrWhiteSpace(p))) +if (new[] { _user, _clientId, _clientSecret }.Any(string.IsNullOrWhiteSpace)) { Console.WriteLine("Please populate authentication details to continue..."); Debugger.Break(); @@ -38,7 +31,7 @@ ClientId = _clientId, ClientSecret = _clientSecret, //FileDataStoreFullPathOverride = _testFolder, - Scopes = new[] { GooglePhotosScope.Access, GooglePhotosScope.Sharing },//Access+Sharing == full access + Scopes = [GooglePhotosScope.Access, GooglePhotosScope.Sharing],//Access+Sharing == full access }; //3) (Optional) display local OAuth 2.0 JSON file(s); @@ -62,17 +55,18 @@ var _googlePhotosSvc = new GooglePhotosService(logger, Options.Create(options), client); //6) log-in -if (!await _googlePhotosSvc.LoginAsync()) throw new Exception($"login failed!"); +if (!await _googlePhotosSvc.LoginAsync()) + throw new GooglePhotosException($"login failed!"); //get existing/create new album var albumTitle = $"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}-{Guid.NewGuid()}";//make-up a random title -var album = await _googlePhotosSvc.GetOrCreateAlbumAsync(albumTitle); -if (album is null) throw new Exception("album creation failed!"); +var album = await _googlePhotosSvc.GetOrCreateAlbumAsync(albumTitle) ?? throw new GooglePhotosException("album creation failed!"); + Console.WriteLine($"{nameof(album)} '{album.title}' id is '{album.id}'"); //upload single media item and assign to album -var mediaItem = await _googlePhotosSvc.UploadSingle($"{_testFolder}test1.jpg", album.id); -if (mediaItem is null) throw new Exception("media item upload failed!"); +var mediaItem = await _googlePhotosSvc.UploadSingle($"{_testFolder}test1.jpg", album.id) ?? throw new GooglePhotosException("media item upload failed!"); + Console.WriteLine($"{nameof(mediaItem)} '{mediaItem.mediaItem.filename}' id is '{mediaItem.mediaItem.id}'"); //retrieve all media items in the album @@ -82,4 +76,4 @@ i++; Console.WriteLine($"{i}\t{item.filename}\t{item.mediaMetadata.width}x{item.mediaMetadata.height}"); } -if (i == 0) throw new Exception("retrieve media items by album id failed!"); +if (i == 0) throw new GooglePhotosException("retrieve media items by album id failed!"); diff --git a/samples/ConsoleApp/Properties/launchSettings.json b/samples/ConsoleApp/Properties/launchSettings.json new file mode 100644 index 0000000..76afbb9 --- /dev/null +++ b/samples/ConsoleApp/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "ConsoleApp": { + "commandName": "Project", + "environmentVariables": { + "NETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/ConsoleApp/Usings.cs b/samples/ConsoleApp/Usings.cs new file mode 100644 index 0000000..1e04ccb --- /dev/null +++ b/samples/ConsoleApp/Usings.cs @@ -0,0 +1,7 @@ +global using CasCap.Exceptions; +global using CasCap.Models; +global using CasCap.Services; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Options; +global using System.Diagnostics; +global using System.Net; diff --git a/samples/GenericHost/GenericHost.csproj b/samples/GenericHost/GenericHost.csproj index 4fa91b7..b0cfa2f 100644 --- a/samples/GenericHost/GenericHost.csproj +++ b/samples/GenericHost/GenericHost.csproj @@ -2,22 +2,21 @@ Exe - net8.0 + net8.0;net9.0 - - - - - - - - + + + + + + + - + diff --git a/samples/GenericHost/Program.cs b/samples/GenericHost/Program.cs index 74007b0..0d60c3d 100644 --- a/samples/GenericHost/Program.cs +++ b/samples/GenericHost/Program.cs @@ -1,23 +1,7 @@ -namespace CasCap; +var builder = Host.CreateApplicationBuilder(args); -public class Program -{ - static readonly string _environmentName = "Development"; +builder.Services.AddGooglePhotos(builder.Configuration); +builder.Services.AddHostedService(); - public static void Main(string[] args) => - Host.CreateDefaultBuilder(args) - .UseEnvironment(_environmentName) - .ConfigureAppConfiguration((hostContext, configBuilder) => - { - configBuilder.AddJsonFile($"appsettings.json", optional: false, reloadOnChange: true); - configBuilder.AddJsonFile($"appsettings.{_environmentName}.json", optional: true, reloadOnChange: true); - if (hostContext.HostingEnvironment.IsDevelopment()) - configBuilder.AddUserSecrets(); - }) - .ConfigureServices((hostContext, services) => - { - services.AddGooglePhotos(); - services.AddHostedService(); - }) - .Build().Run(); -} +IHost host = builder.Build(); +host.Run(); diff --git a/samples/GenericHost/Properties/launchSettings.json b/samples/GenericHost/Properties/launchSettings.json new file mode 100644 index 0000000..874eebf --- /dev/null +++ b/samples/GenericHost/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "GenericHost": { + "commandName": "Project", + "environmentVariables": { + "NETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/GenericHost/Services/MyBackgroundService.cs b/samples/GenericHost/Services/MyBackgroundService.cs index 1183d7b..4305263 100644 --- a/samples/GenericHost/Services/MyBackgroundService.cs +++ b/samples/GenericHost/Services/MyBackgroundService.cs @@ -18,33 +18,33 @@ public MyBackgroundService(ILogger logger, IHostApplication protected async override Task ExecuteAsync(CancellationToken cancellationToken) { - _logger.LogDebug($"starting {nameof(ExecuteAsync)}..."); + _logger.LogInformation("{serviceName} starting {methodName}...", nameof(MyBackgroundService), nameof(ExecuteAsync)); //log-in - if (!await _googlePhotosSvc.LoginAsync()) throw new Exception($"login failed!"); + if (!await _googlePhotosSvc.LoginAsync(cancellationToken)) throw new GooglePhotosException($"login failed!"); //get existing/create new album var albumTitle = $"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}-{Guid.NewGuid()}";//make-up a random title - var album = await _googlePhotosSvc.GetOrCreateAlbumAsync(albumTitle); - if (album is null) throw new Exception("album creation failed!"); - Console.WriteLine($"{nameof(album)} '{album.title}' id is '{album.id}'"); + var album = await _googlePhotosSvc.GetOrCreateAlbumAsync(albumTitle) ?? throw new GooglePhotosException("album creation failed!"); + _logger.LogInformation("{serviceName} {name} '{title}' id is '{id}'", nameof(MyBackgroundService), nameof(album), album.title, album.id); //upload single media item and assign to album - var mediaItem = await _googlePhotosSvc.UploadSingle($"{_testFolder}test1.jpg", album.id); - if (mediaItem is null) throw new Exception("media item upload failed!"); - Console.WriteLine($"{nameof(mediaItem)} '{mediaItem.mediaItem.filename}' id is '{mediaItem.mediaItem.id}'"); + var path = $"{_testFolder}test1.jpg"; + var mediaItem = await _googlePhotosSvc.UploadSingle(path, album.id) ?? throw new GooglePhotosException($"media item '{path}' upload failed!"); + _logger.LogInformation("{serviceName} {name} '{filename}' id is '{id}'", + nameof(MyBackgroundService), nameof(mediaItem), mediaItem.mediaItem.filename, mediaItem.mediaItem.id); //retrieve all media items in the album - var albumMediaItems = await _googlePhotosSvc.GetMediaItemsByAlbumAsync(album.id, cancellationToken: cancellationToken).ToListAsync(); - if (albumMediaItems is null) throw new Exception("retrieve media items by album id failed!"); + var albumMediaItems = await _googlePhotosSvc.GetMediaItemsByAlbumAsync(album.id, cancellationToken: cancellationToken).ToListAsync(cancellationToken) ?? throw new GooglePhotosException("retrieve media items by album id failed!"); var i = 1; foreach (var item in albumMediaItems) { - Console.WriteLine($"{i}\t{item.filename}\t{item.mediaMetadata.width}x{item.mediaMetadata.height}"); + _logger.LogInformation("{serviceName} album #{i} {filename} {width}x{height}", nameof(MyBackgroundService), i, item.filename, + item.mediaMetadata.width, item.mediaMetadata.height); i++; } - _logger.LogDebug($"exiting {nameof(ExecuteAsync)}..."); + _logger.LogInformation("{serviceName} exiting {methodName}...", nameof(MyBackgroundService), nameof(ExecuteAsync)); _appLifetime.StopApplication(); } } diff --git a/samples/GenericHost/Usings.cs b/samples/GenericHost/Usings.cs index c3f8354..2d52bc8 100644 --- a/samples/GenericHost/Usings.cs +++ b/samples/GenericHost/Usings.cs @@ -1,4 +1,5 @@ -global using CasCap.Services; +global using CasCap.Exceptions; +global using CasCap.Services; global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; diff --git a/src/CasCap.Apis.GooglePhotos.Tests/CasCap.Apis.GooglePhotos.Tests.csproj b/src/CasCap.Api.GooglePhotos.Tests/CasCap.Api.GooglePhotos.Tests.csproj similarity index 69% rename from src/CasCap.Apis.GooglePhotos.Tests/CasCap.Apis.GooglePhotos.Tests.csproj rename to src/CasCap.Api.GooglePhotos.Tests/CasCap.Api.GooglePhotos.Tests.csproj index b94ea12..d73687f 100644 --- a/src/CasCap.Apis.GooglePhotos.Tests/CasCap.Apis.GooglePhotos.Tests.csproj +++ b/src/CasCap.Api.GooglePhotos.Tests/CasCap.Api.GooglePhotos.Tests.csproj @@ -1,34 +1,41 @@  - net8.0 + net8.0;net9.0 - - - - - - - - - + + + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + - + diff --git a/src/CasCap.Api.GooglePhotos.Tests/Tests/ExifTests.cs b/src/CasCap.Api.GooglePhotos.Tests/Tests/ExifTests.cs new file mode 100644 index 0000000..00f8a98 --- /dev/null +++ b/src/CasCap.Api.GooglePhotos.Tests/Tests/ExifTests.cs @@ -0,0 +1,103 @@ +//namespace CasCap.Tests; + +//public class ExifTests : TestBase +//{ +// public ExifTests(ITestOutputHelper output) : base(output) { } + +// /// +// /// Minimal exif tags added by Google. +// /// +// const int googleExifTagCount = 5; + +// [SkipIfCIBuildTheory, Trait("Type", nameof(GooglePhotosService))] +// [InlineData("test11.jpg", 55.041388888888889d, 8.4677777777777781d, 62)] +// public async Task CheckExifData(string fileName, double latitude, double longitude, int exifTagCount) +// { +// var path = $"{_testFolder}{fileName}"; +// var originalBytes = File.ReadAllBytes(path); + +// var loginResult = await _googlePhotosSvc.LoginAsync(); +// Assert.True(loginResult); + +// var tplOriginal = await GetExifInfo(path); +// Assert.Equal(latitude, tplOriginal.latitude); +// Assert.Equal(longitude, tplOriginal.longitude); +// Assert.Equal(exifTagCount, tplOriginal.exifTagCount); + +// var uploadToken = await _googlePhotosSvc.UploadMediaAsync(path, GooglePhotosUploadMethod.Simple); +// Assert.NotNull(uploadToken); +// var newMediaItemResult = await _googlePhotosSvc.AddMediaItemAsync(uploadToken, path); +// Assert.NotNull(newMediaItemResult); +// //the upload returns a null baseUrl +// Assert.Null(newMediaItemResult.mediaItem.baseUrl); + +// //so now retrieve all media items +// var mediaItems = await _googlePhotosSvc.GetMediaItemsAsync().ToListAsync(); + +// var uploadedMediaItem = mediaItems.FirstOrDefault(p => p.filename.Equals(fileName)); +// Assert.NotNull(uploadedMediaItem); +// Assert.True(uploadedMediaItem.isPhoto); + +// var bytesNoExif = await _googlePhotosSvc.DownloadBytes(uploadedMediaItem, includeExifMetadata: false); +// Assert.NotNull(bytesNoExif); +// var tplNoExif = await ExifTests.GetExifInfo(bytesNoExif); +// Assert.True(googleExifTagCount == tplNoExif.exifTagCount); + +// var bytesWithExif = await _googlePhotosSvc.DownloadBytes(uploadedMediaItem, includeExifMetadata: true); +// Assert.NotNull(bytesWithExif); +// var tplWithExif = await ExifTests.GetExifInfo(bytesWithExif); +// Assert.Null(tplWithExif.latitude);//location exif data always stripped :( +// Assert.Null(tplWithExif.longitude);//location exif data always stripped :( +// Assert.True(tplOriginal.exifTagCount > tplWithExif.exifTagCount);//due to Google-stripping fewer exif tags are returned +// Assert.True(googleExifTagCount < tplWithExif.exifTagCount); +// } + +// static async Task<(double? latitude, double? longitude, int exifTagCount)> GetExifInfo(string path) +// { +// using var image = await Image.LoadAsync(path); +// return GetLatLong(image); +// } + +// static async Task<(double? latitude, double? longitude, int exifTagCount)> GetExifInfo(byte[] bytes) +// { +// var stream = new MemoryStream(bytes); +// using var image = await Image.LoadAsync(stream); +// return GetLatLong(image); +// } + +// static (double? latitude, double? longitude, int exifTagCount) GetLatLong(Image image) +// { +// double? latitude = null, longitude = null; +// var exifTagCount = image.Metadata.ExifProfile?.Values.Count ?? 0; +// if (image.Metadata.ExifProfile.Values?.Any() ?? false) +// { +// var exifData = image.Metadata.ExifProfile; +// if (exifData != null) +// { +// if (exifData.TryGetValue(ExifTag.GPSLatitude, out var gpsLatitude) +// && exifData.TryGetValue(ExifTag.GPSLatitudeRef, out var gpsLatitudeRef)) +// latitude = GetCoordinates(gpsLatitudeRef.ToString(), gpsLatitude.Value); + +// if (exifData.TryGetValue(ExifTag.GPSLongitude, out var gpsLong) +// && exifData.TryGetValue(ExifTag.GPSLongitudeRef, out var gpsLongRef)) +// longitude = GetCoordinates(gpsLongRef.ToString(), gpsLong.Value); + +// Debug.WriteLine($"latitude,longitude = {latitude},{longitude}"); +// } +// } + +// return (latitude, longitude, exifTagCount); +// } + +// static double GetCoordinates(string gpsRef, Rational[] rationals) +// { +// var degrees = rationals[0].Numerator / rationals[0].Denominator; +// var minutes = rationals[1].Numerator / rationals[1].Denominator; +// var seconds = rationals[2].Numerator / rationals[2].Denominator; + +// var coordinate = degrees + (minutes / 60d) + (seconds / 3600d); +// if (gpsRef == "S" || gpsRef == "W") +// coordinate *= -1; +// return coordinate; +// } +//} diff --git a/src/CasCap.Apis.GooglePhotos.Tests/Tests/TestBase.cs b/src/CasCap.Api.GooglePhotos.Tests/Tests/TestBase.cs similarity index 80% rename from src/CasCap.Apis.GooglePhotos.Tests/Tests/TestBase.cs rename to src/CasCap.Api.GooglePhotos.Tests/Tests/TestBase.cs index fbf1018..87727b9 100644 --- a/src/CasCap.Apis.GooglePhotos.Tests/Tests/TestBase.cs +++ b/src/CasCap.Api.GooglePhotos.Tests/Tests/TestBase.cs @@ -1,9 +1,4 @@ -using CasCap.Services; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Xunit.Abstractions; -namespace CasCap.Apis.GooglePhotos.Tests; +namespace CasCap.Tests; public abstract class TestBase { @@ -29,7 +24,7 @@ public TestBase(ITestOutputHelper output) _logger = ApplicationLogging.LoggerFactory.CreateLogger(); //add services - services.AddGooglePhotos(); + services.AddGooglePhotos(configuration); //retrieve services var serviceProvider = services.BuildServiceProvider(); diff --git a/src/CasCap.Apis.GooglePhotos.Tests/Tests/Tests.cs b/src/CasCap.Api.GooglePhotos.Tests/Tests/Tests.cs similarity index 78% rename from src/CasCap.Apis.GooglePhotos.Tests/Tests/Tests.cs rename to src/CasCap.Api.GooglePhotos.Tests/Tests/Tests.cs index 99a5083..e97ef15 100644 --- a/src/CasCap.Apis.GooglePhotos.Tests/Tests/Tests.cs +++ b/src/CasCap.Api.GooglePhotos.Tests/Tests/Tests.cs @@ -1,35 +1,46 @@ -using CasCap.Common.Extensions; -using CasCap.Models; -using CasCap.Services; -using CasCap.Xunit; -using System.Diagnostics; -using Xunit; -using Xunit.Abstractions; -namespace CasCap.Apis.GooglePhotos.Tests; +namespace CasCap.Tests; /// -/// Integration tests for GooglePhotos API library, update appsettings.Test.json with appropriate login values before running. +/// Integration tests for CasCap.Api.GooglePhotos library. +/// For local testing update appsettings.Test.json and/or add values to UserSecrets before running. +/// +/// When running integration tests under GitHub Actions you should first run the tests locally with the test account +/// and then update the GitHub Actions secret to the access_token from the local JSON file, e.g. +/// C:\Users\???\AppData\Roaming\Google.Apis.Auth\Google.Apis.Auth.OAuth2.Responses.TokenResponse-???@???.com +/// This is because the current method of authentication used by CasCap.Api.GooglePhotos requires +/// browser interaction which is not possible during CI. /// -public class Tests : TestBase +public class Tests(ITestOutputHelper output) : TestBase(output) { - public Tests(ITestOutputHelper output) : base(output) { } - [SkipIfCIBuildFact] - public async Task LoginTest() + public async Task DoLogin() { - var loginResult = await _googlePhotosSvc.LoginAsync(); - Assert.True(loginResult); + if (IsCI()) + { + var accessToken = Environment.GetEnvironmentVariable("GOOGLE_PHOTOS_ACCESS_TOKEN"); + if (string.IsNullOrWhiteSpace(accessToken)) throw new ArgumentNullException(nameof(accessToken)); + _googlePhotosSvc.SetAuth("Bearer", accessToken); + return true; + } + else + { + Assert.True(true); + return await _googlePhotosSvc.LoginAsync(); + } } + static bool IsCI() => Environment.GetEnvironmentVariable("TF_BUILD") is not null + || Environment.GetEnvironmentVariable("GITHUB_ACTIONS") is not null; + static string GetRandomAlbumName() => $"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}"; - [SkipIfCIBuildTheory, Trait("Type", nameof(GooglePhotosService))] + [Theory, Trait("Type", nameof(GooglePhotosService))] [InlineData(GooglePhotosUploadMethod.Simple)] [InlineData(GooglePhotosUploadMethod.ResumableSingle)] [InlineData(GooglePhotosUploadMethod.ResumableMultipart)] public async Task UploadMediaTests(GooglePhotosUploadMethod uploadMethod) { - var loginResult = await _googlePhotosSvc.LoginAsync(); + var loginResult = await DoLogin(); Assert.True(loginResult); var paths = Directory.GetFiles(_testFolder); @@ -44,12 +55,12 @@ public async Task UploadMediaTests(GooglePhotosUploadMethod uploadMethod) } } - [SkipIfCIBuildTheory] + [Theory] [InlineData("test1.jpg", "test2.jpg")] [InlineData("test1.jpg", "Урок-английского-10.jpg")] public async Task UploadSingleTests(string file1, string file2) { - var loginResult = await _googlePhotosSvc.LoginAsync(); + var loginResult = await DoLogin(); Assert.True(loginResult); //upload single media item @@ -82,13 +93,13 @@ public async Task UploadSingleTests(string file1, string file2) //retrieve all media items from album var albumMediaItems = await _googlePhotosSvc.GetMediaItemsByAlbumAsync(album.id).ToListAsync(); Assert.NotNull(albumMediaItems); - Assert.True(albumMediaItems.Count == 1); + Assert.Single(albumMediaItems); } - [SkipIfCIBuildFact] + [Fact] public async Task UploadMultipleTests() { - var loginResult = await _googlePhotosSvc.LoginAsync(); + var loginResult = await DoLogin(); Assert.True(loginResult); //upload multiple media items @@ -145,18 +156,18 @@ public async Task UploadMultipleTests() ids.Add("invalid-id"); var mediaItems2 = await _googlePhotosSvc.GetMediaItemsByIdsAsync(ids.ToArray()).ToListAsync(); Assert.NotNull(mediaItems2); - Assert.True(ids.Count - mediaItems2.Count == 1);//should have 1 failed item + Assert.Equal(1, ids.Count - mediaItems2.Count);//should have 1 failed item foreach (var _mi in mediaItems2) { - Debug.WriteLine(_mi.ToJSON()); + Debug.WriteLine(_mi.ToJson()); } Assert.True(true); } - [SkipIfCIBuildFact] + [Fact] public async Task FilteringTests() { - var loginResult = await _googlePhotosSvc.LoginAsync(); + var loginResult = await DoLogin(); Assert.True(loginResult); contentFilter contentFilter = null; @@ -165,19 +176,19 @@ public async Task FilteringTests() contentFilter = new contentFilter #pragma warning restore CS0162 // Unreachable code detected { - includedContentCategories = new[] { GooglePhotosContentCategoryType.PEOPLE }, - //includedContentCategories = new[] { contentCategoryType.WEDDINGS }, - //excludedContentCategories = new[] { contentCategoryType.PEOPLE } + includedContentCategories = [GooglePhotosContentCategoryType.PEOPLE], + //includedContentCategories = [contentCategoryType.WEDDINGS], + //excludedContentCategories = [contentCategoryType.PEOPLE] }; dateFilter dateFilter = new() { - //dates = new gDate[] { new gDate { year = 2020 } }, - //dates = new gDate[] { new gDate { year = 2016 } }, - //dates = new gDate[] { new gDate { year = 2016, month = 12 } }, - //dates = new gDate[] { new gDate { year = 2016, month = 12, day = 16 } }, - //ranges = new gDateRange[] { new gDateRange { startDate = new startDate { year = 2016 }, endDate = new endDate { year = 2017 } } }, - ranges = new gDateRange[] { new gDateRange { startDate = new gDate { year = 1900 }, endDate = new gDate { year = DateTime.UtcNow.Year } } }, + //dates = [new() { year = 2020 }], + //dates = [new() { year = 2016 }], + //dates = [new() { year = 2016, month = 12 }], + //dates = [new() { year = 2016, month = 12, day = 16 }], + //ranges = [new() { startDate = new() { year = 2016 }, endDate = new() { year = 2017 } }], + ranges = [new() { startDate = new() { year = 1900 }, endDate = new() { year = DateTime.UtcNow.Year } }], }; mediaTypeFilter mediaTypeFilter = null; if (false) @@ -185,8 +196,8 @@ public async Task FilteringTests() mediaTypeFilter = new mediaTypeFilter #pragma warning restore CS0162 // Unreachable code detected { - mediaTypes = new[] { GooglePhotosMediaType.PHOTO } - //mediaTypes = new[] { mediaType.VIDEO } + mediaTypes = [GooglePhotosMediaType.PHOTO] + //mediaTypes = [mediaType.VIDEO] }; featureFilter featureFilter = null; if (false) @@ -194,7 +205,7 @@ public async Task FilteringTests() featureFilter = new featureFilter #pragma warning restore CS0162 // Unreachable code detected { - includedFeatures = new[] { GooglePhotosFeatureType.FAVORITES } + includedFeatures = [GooglePhotosFeatureType.FAVORITES] }; var filter = new Filter { @@ -214,10 +225,10 @@ public async Task FilteringTests() Assert.True(true); } - [SkipIfCIBuildFact] + [Fact] public async Task EnrichmentsTests() { - var loginResult = await _googlePhotosSvc.LoginAsync(); + var loginResult = await DoLogin(); Assert.True(loginResult); var path = $"{_testFolder}test7.jpg"; @@ -254,10 +265,10 @@ public async Task EnrichmentsTests() Assert.NotNull(enrichmentId2); } - [SkipIfCIBuildFact] + [Fact] public async Task SharingTests() { - var loginResult = await _googlePhotosSvc.LoginAsync(); + var loginResult = await DoLogin(); Assert.True(loginResult); //get or create new album @@ -276,7 +287,7 @@ public async Task SharingTests() //get album contents var mediaItems1 = await _googlePhotosSvc.GetMediaItemsByAlbumAsync(album.id).ToListAsync(); Assert.NotNull(mediaItems1); - Assert.True(mediaItems1.Count == 1); + Assert.Single(mediaItems1); //remove from album var result2 = await _googlePhotosSvc.RemoveMediaItemsFromAlbumAsync(album.id, new[] { mediaItem.mediaItem.id }); @@ -285,7 +296,7 @@ public async Task SharingTests() //get album contents var mediaItems2 = await _googlePhotosSvc.GetMediaItemsByAlbumAsync(album.id).ToListAsync(); Assert.NotNull(mediaItems2); - Assert.True(mediaItems2.Count == 0); + Assert.Empty(mediaItems2); //re-add same pic to album var result3 = await _googlePhotosSvc.AddMediaItemsToAlbumAsync(album.id, new[] { mediaItem.mediaItem.id }); @@ -294,7 +305,7 @@ public async Task SharingTests() //get album contents var mediaItems3 = await _googlePhotosSvc.GetMediaItemsByAlbumAsync(album.id).ToListAsync(); Assert.NotNull(mediaItems3); - Assert.True(mediaItems3.Count == 1); + Assert.Single(mediaItems3); //enable sharing on album var shareInfo = await _googlePhotosSvc.ShareAlbumAsync(album.id); @@ -304,7 +315,7 @@ public async Task SharingTests() //retrieve shared albums var sharedAlbums = await _googlePhotosSvc.GetSharedAlbumsAsync(); - Assert.True(sharedAlbums.Count == 1); + Assert.Single(sharedAlbums); var sharedAlb1a = await _googlePhotosSvc.GetAlbumAsync(album.id); Assert.NotNull(sharedAlb1a); @@ -317,8 +328,8 @@ public async Task SharingTests() Assert.True(result4); } - //[SkipIfCIBuildFact] - [SkipIfCIBuildTheory] + //[Fact] + [Theory] [InlineData(1, 10)] [InlineData(1, 100)] [InlineData(2, 10)] @@ -330,15 +341,15 @@ public async Task SharingTests() [InlineData(20, 10)] public async Task DownloadBytesTests(int pageSize, int maxPageCount) { - var expectedCount = Directory.GetFiles(_testFolder).Length; + //var expectedCount = Directory.GetFiles(_testFolder).Length; - var loginResult = await _googlePhotosSvc.LoginAsync(); + var loginResult = await DoLogin(); Assert.True(loginResult); var mediaItems = await _googlePhotosSvc.GetMediaItemsAsync(pageSize, maxPageCount).ToListAsync(); Assert.NotNull(mediaItems); Assert.True(mediaItems.Count > 0, "no media items returned!"); - Assert.True(mediaItems.Select(p => p.id).Distinct().Count() == expectedCount, $"inaccurate list of media items returned, expected {expectedCount} but returned {mediaItems.Count}"); + //Assert.True(mediaItems.Select(p => p.id).Distinct().Count() == expectedCount, $"inaccurate list of media items returned, expected {expectedCount} but returned {mediaItems.Count}"); var bytes = await _googlePhotosSvc.DownloadBytes(mediaItems[0]); Assert.NotNull(bytes); diff --git a/src/CasCap.Api.GooglePhotos.Tests/Usings.cs b/src/CasCap.Api.GooglePhotos.Tests/Usings.cs new file mode 100644 index 0000000..fdf648d --- /dev/null +++ b/src/CasCap.Api.GooglePhotos.Tests/Usings.cs @@ -0,0 +1,12 @@ +global using CasCap.Common.Extensions; +global using CasCap.Models; +global using CasCap.Services; +global using CasCap.Xunit; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using SixLabors.ImageSharp; +global using SixLabors.ImageSharp.Metadata.Profiles.Exif; +global using System.Diagnostics; +global using Xunit; +global using Xunit.Abstractions; diff --git a/src/CasCap.Apis.GooglePhotos.Tests/appsettings.Test.json b/src/CasCap.Api.GooglePhotos.Tests/appsettings.Test.json similarity index 100% rename from src/CasCap.Apis.GooglePhotos.Tests/appsettings.Test.json rename to src/CasCap.Api.GooglePhotos.Tests/appsettings.Test.json diff --git a/src/CasCap.Apis.GooglePhotos.Tests/testdata/test.mp4 b/src/CasCap.Api.GooglePhotos.Tests/testdata/test.mp4 similarity index 100% rename from src/CasCap.Apis.GooglePhotos.Tests/testdata/test.mp4 rename to src/CasCap.Api.GooglePhotos.Tests/testdata/test.mp4 diff --git a/src/CasCap.Apis.GooglePhotos.Tests/testdata/test0.jpg b/src/CasCap.Api.GooglePhotos.Tests/testdata/test0.jpg similarity index 100% rename from src/CasCap.Apis.GooglePhotos.Tests/testdata/test0.jpg rename to src/CasCap.Api.GooglePhotos.Tests/testdata/test0.jpg diff --git a/src/CasCap.Apis.GooglePhotos.Tests/testdata/test1.jpg b/src/CasCap.Api.GooglePhotos.Tests/testdata/test1.jpg similarity index 100% rename from src/CasCap.Apis.GooglePhotos.Tests/testdata/test1.jpg rename to src/CasCap.Api.GooglePhotos.Tests/testdata/test1.jpg diff --git a/src/CasCap.Apis.GooglePhotos.Tests/testdata/test11.jpg b/src/CasCap.Api.GooglePhotos.Tests/testdata/test11.jpg similarity index 100% rename from src/CasCap.Apis.GooglePhotos.Tests/testdata/test11.jpg rename to src/CasCap.Api.GooglePhotos.Tests/testdata/test11.jpg diff --git a/src/CasCap.Apis.GooglePhotos.Tests/testdata/test2.jpg b/src/CasCap.Api.GooglePhotos.Tests/testdata/test2.jpg similarity index 100% rename from src/CasCap.Apis.GooglePhotos.Tests/testdata/test2.jpg rename to src/CasCap.Api.GooglePhotos.Tests/testdata/test2.jpg diff --git a/src/CasCap.Apis.GooglePhotos.Tests/testdata/test3.jpg b/src/CasCap.Api.GooglePhotos.Tests/testdata/test3.jpg similarity index 100% rename from src/CasCap.Apis.GooglePhotos.Tests/testdata/test3.jpg rename to src/CasCap.Api.GooglePhotos.Tests/testdata/test3.jpg diff --git a/src/CasCap.Apis.GooglePhotos.Tests/testdata/test4.jpg b/src/CasCap.Api.GooglePhotos.Tests/testdata/test4.jpg similarity index 100% rename from src/CasCap.Apis.GooglePhotos.Tests/testdata/test4.jpg rename to src/CasCap.Api.GooglePhotos.Tests/testdata/test4.jpg diff --git a/src/CasCap.Apis.GooglePhotos.Tests/testdata/test5.jpg b/src/CasCap.Api.GooglePhotos.Tests/testdata/test5.jpg similarity index 100% rename from src/CasCap.Apis.GooglePhotos.Tests/testdata/test5.jpg rename to src/CasCap.Api.GooglePhotos.Tests/testdata/test5.jpg diff --git a/src/CasCap.Apis.GooglePhotos.Tests/testdata/test6.jpg b/src/CasCap.Api.GooglePhotos.Tests/testdata/test6.jpg similarity index 100% rename from src/CasCap.Apis.GooglePhotos.Tests/testdata/test6.jpg rename to src/CasCap.Api.GooglePhotos.Tests/testdata/test6.jpg diff --git a/src/CasCap.Apis.GooglePhotos.Tests/testdata/test7.jpg b/src/CasCap.Api.GooglePhotos.Tests/testdata/test7.jpg similarity index 100% rename from src/CasCap.Apis.GooglePhotos.Tests/testdata/test7.jpg rename to src/CasCap.Api.GooglePhotos.Tests/testdata/test7.jpg diff --git a/src/CasCap.Apis.GooglePhotos.Tests/testdata/test8.jpg b/src/CasCap.Api.GooglePhotos.Tests/testdata/test8.jpg similarity index 100% rename from src/CasCap.Apis.GooglePhotos.Tests/testdata/test8.jpg rename to src/CasCap.Api.GooglePhotos.Tests/testdata/test8.jpg diff --git a/src/CasCap.Apis.GooglePhotos.Tests/testdata/test9.jpg b/src/CasCap.Api.GooglePhotos.Tests/testdata/test9.jpg similarity index 100% rename from src/CasCap.Apis.GooglePhotos.Tests/testdata/test9.jpg rename to src/CasCap.Api.GooglePhotos.Tests/testdata/test9.jpg diff --git "a/src/CasCap.Apis.GooglePhotos.Tests/testdata/\320\243\321\200\320\276\320\272-\320\260\320\275\320\263\320\273\320\270\320\271\321\201\320\272\320\276\320\263\320\276-10.jpg" "b/src/CasCap.Api.GooglePhotos.Tests/testdata/\320\243\321\200\320\276\320\272-\320\260\320\275\320\263\320\273\320\270\320\271\321\201\320\272\320\276\320\263\320\276-10.jpg" similarity index 100% rename from "src/CasCap.Apis.GooglePhotos.Tests/testdata/\320\243\321\200\320\276\320\272-\320\260\320\275\320\263\320\273\320\270\320\271\321\201\320\272\320\276\320\263\320\276-10.jpg" rename to "src/CasCap.Api.GooglePhotos.Tests/testdata/\320\243\321\200\320\276\320\272-\320\260\320\275\320\263\320\273\320\270\320\271\321\201\320\272\320\276\320\263\320\276-10.jpg" diff --git a/src/CasCap.Apis.GooglePhotos/Interfaces/IPagingToken.cs b/src/CasCap.Api.GooglePhotos/Abstractions/IPagingToken.cs similarity index 83% rename from src/CasCap.Apis.GooglePhotos/Interfaces/IPagingToken.cs rename to src/CasCap.Api.GooglePhotos/Abstractions/IPagingToken.cs index 3995a30..b4057a7 100644 --- a/src/CasCap.Apis.GooglePhotos/Interfaces/IPagingToken.cs +++ b/src/CasCap.Api.GooglePhotos/Abstractions/IPagingToken.cs @@ -1,4 +1,4 @@ -namespace CasCap.Interfaces; +namespace CasCap.Abstractions; public interface IPagingToken { diff --git a/src/CasCap.Apis.GooglePhotos/CasCap.Apis.GooglePhotos.csproj b/src/CasCap.Api.GooglePhotos/CasCap.Api.GooglePhotos.csproj similarity index 54% rename from src/CasCap.Apis.GooglePhotos/CasCap.Apis.GooglePhotos.csproj rename to src/CasCap.Api.GooglePhotos/CasCap.Api.GooglePhotos.csproj index 57a0f84..536cc7e 100644 --- a/src/CasCap.Apis.GooglePhotos/CasCap.Apis.GooglePhotos.csproj +++ b/src/CasCap.Api.GooglePhotos/CasCap.Api.GooglePhotos.csproj @@ -1,7 +1,7 @@  - net8.0 + net8.0;net9.0 true enable @@ -19,18 +19,31 @@ For more details about the underlying API see the official site, https://developers.google.com/photos - For usage examples see the docs on github, https://github.com/f2calv/CasCap.Apis.GooglePhotos + For usage examples see the docs on github, https://github.com/f2calv/CasCap.Api.GooglePhotos google, photos, rest, api, wrapper - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/src/CasCap.Api.GooglePhotos/Exceptions/GooglePhotosException.cs b/src/CasCap.Api.GooglePhotos/Exceptions/GooglePhotosException.cs new file mode 100644 index 0000000..4e57c98 --- /dev/null +++ b/src/CasCap.Api.GooglePhotos/Exceptions/GooglePhotosException.cs @@ -0,0 +1,23 @@ +using System.Runtime.Serialization; + +namespace CasCap.Exceptions; + +[Serializable] +public class GooglePhotosException : Exception +{ + public GooglePhotosException() { } + public GooglePhotosException(string message) : base(message) { } + public GooglePhotosException(string message, Exception? innerException) : base(message, innerException) { } + + public GooglePhotosException(Error error) + : base(error is not null && error.error is not null && error.error.message is not null ? error.error.message : "unknown") { } + + [Obsolete("added to pass sonarqube", DiagnosticId = "SYSLIB0051")] + protected GooglePhotosException(SerializationInfo info, StreamingContext context) : base(info, context) { } + + [Obsolete("added to pass sonarqube", DiagnosticId = "SYSLIB0051")] + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + } +} diff --git a/src/CasCap.Api.GooglePhotos/Extensions/DI.cs b/src/CasCap.Api.GooglePhotos/Extensions/DI.cs new file mode 100644 index 0000000..41a5622 --- /dev/null +++ b/src/CasCap.Api.GooglePhotos/Extensions/DI.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class DI +{ + public static void AddGooglePhotos(this IServiceCollection services, IConfiguration configuration, string sectionKey = GooglePhotosOptions.SectionKey) + => services.AddServices(configuration: configuration, sectionKey: sectionKey); + + public static void AddGooglePhotos(this IServiceCollection services, GooglePhotosOptions googlePhotosOptions) + => services.AddServices(googlePhotosOptions: googlePhotosOptions); + + public static void AddGooglePhotos(this IServiceCollection services, Action configureOptions) + => services.AddServices(configureOptions: configureOptions); + + static void AddServices(this IServiceCollection services, + IConfiguration? configuration = null, + string sectionKey = GooglePhotosOptions.SectionKey, + GooglePhotosOptions? googlePhotosOptions = null, + Action? configureOptions = null + ) + { + if (configuration is not null) + { + var configSection = configuration.GetSection(sectionKey); + googlePhotosOptions = configSection.Get(); + if (googlePhotosOptions is not null) + services.Configure(configSection); + } + else if (googlePhotosOptions is not null) + { + var options = Options.Options.Create(googlePhotosOptions); + services.AddSingleton(options); + } + else if (configureOptions is not null) + { + services.Configure(configureOptions); + googlePhotosOptions = new(); + configureOptions.Invoke(googlePhotosOptions); + } + if (googlePhotosOptions is null) + throw new GooglePhotosException($"configuration object {nameof(GooglePhotosOptions)} is null so cannot continue"); + + services.AddHttpClient((s, client) => + { + client.BaseAddress = new Uri(googlePhotosOptions.BaseAddress); + client.DefaultRequestHeaders.Add("User-Agent", $"{nameof(CasCap)}.{AppDomain.CurrentDomain.FriendlyName}.{Environment.MachineName}"); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip")); + client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("deflate")); + client.Timeout = Timeout.InfiniteTimeSpan; + }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }) + //https://github.com/aspnet/AspNetCore/issues/6804 + .SetHandlerLifetime(Timeout.InfiniteTimeSpan) + .AddStandardResilienceHandler((options) => + { + //RateLimiter + options.TotalRequestTimeout = new Http.Resilience.HttpTimeoutStrategyOptions + { + Timeout = TimeSpan.FromSeconds(90) + }; + //Retry + options.Retry = new Http.Resilience.HttpRetryStrategyOptions + { + MaxRetryAttempts = 6 + }; + //Circuit Breaker + options.CircuitBreaker = new Http.Resilience.HttpCircuitBreakerStrategyOptions + { + SamplingDuration = TimeSpan.FromSeconds(180) + }; + //AttemptTimeout + options.AttemptTimeout = new Http.Resilience.HttpTimeoutStrategyOptions + { + Timeout = TimeSpan.FromSeconds(90) + }; + }); + } +} diff --git a/src/CasCap.Apis.GooglePhotos/Messages/Responses.cs b/src/CasCap.Api.GooglePhotos/Messages/Responses.cs similarity index 85% rename from src/CasCap.Apis.GooglePhotos/Messages/Responses.cs rename to src/CasCap.Api.GooglePhotos/Messages/Responses.cs index 21f5b41..951eefb 100644 --- a/src/CasCap.Apis.GooglePhotos/Messages/Responses.cs +++ b/src/CasCap.Api.GooglePhotos/Messages/Responses.cs @@ -22,23 +22,17 @@ public class mediaItemsCreateResponse//todo: should this be internal? /// /// https://developers.google.com/photos/library/reference/rest/v1/albums/addEnrichment#request-body /// -internal class AddEnrichmentRequest +internal class AddEnrichmentRequest(NewEnrichmentItem newEnrichmentItem, AlbumPosition albumPosition) { - public AddEnrichmentRequest(NewEnrichmentItem newEnrichmentItem, AlbumPosition albumPosition) - { - this.newEnrichmentItem = newEnrichmentItem; - this.albumPosition = albumPosition; - } - /// /// The enrichment to be added. /// - public NewEnrichmentItem newEnrichmentItem { get; set; } + public NewEnrichmentItem newEnrichmentItem { get; set; } = newEnrichmentItem; /// /// The position in the album where the enrichment is to be inserted. /// - public AlbumPosition albumPosition { get; set; } + public AlbumPosition albumPosition { get; set; } = albumPosition; } /// diff --git a/src/CasCap.Apis.GooglePhotos/Models/GooglePhotos.cs b/src/CasCap.Api.GooglePhotos/Models/GooglePhotos.cs similarity index 89% rename from src/CasCap.Apis.GooglePhotos/Models/GooglePhotos.cs rename to src/CasCap.Api.GooglePhotos/Models/GooglePhotos.cs index d07a900..c9abe7b 100644 --- a/src/CasCap.Apis.GooglePhotos/Models/GooglePhotos.cs +++ b/src/CasCap.Api.GooglePhotos/Models/GooglePhotos.cs @@ -1,8 +1,6 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -namespace CasCap.Models; +namespace CasCap.Models; -[JsonConverter(typeof(StringEnumConverter))] +[JsonConverter(typeof(JsonStringEnumConverter))] public enum GooglePhotosScope { /// @@ -26,12 +24,12 @@ public enum GooglePhotosScope /// /// Read access to media items and albums created by the developer. For more information, see Access media items and List library contents, albums, and media items. /// - /// Intended to be requested together with the.appendonly scope. + /// Intended to be requested together with the AppendOnly scope. /// AppCreatedData, /// - /// Access to both the .appendonly and .readonly scopes. Doesn't include .sharing. + /// Access to both the AppendOnly and ReadOnly scopes. Doesn't include Sharing scope. /// Access, @@ -43,7 +41,7 @@ public enum GooglePhotosScope Sharing } -[JsonConverter(typeof(StringEnumConverter))] +[JsonConverter(typeof(JsonStringEnumConverter))] public enum GooglePhotosUploadMethod { Simple, @@ -51,7 +49,7 @@ public enum GooglePhotosUploadMethod ResumableMultipart } -[JsonConverter(typeof(StringEnumConverter))] +[JsonConverter(typeof(JsonStringEnumConverter))] public enum GooglePhotosPositionType { /// @@ -80,20 +78,20 @@ public enum GooglePhotosPositionType AFTER_ENRICHMENT_ITEM } -[JsonConverter(typeof(StringEnumConverter))] +[JsonConverter(typeof(JsonStringEnumConverter))] public enum GooglePhotosMediaType { PHOTO, VIDEO } -[JsonConverter(typeof(StringEnumConverter))] +[JsonConverter(typeof(JsonStringEnumConverter))] public enum GooglePhotosFeatureType { FAVORITES } -[JsonConverter(typeof(StringEnumConverter))] +[JsonConverter(typeof(JsonStringEnumConverter))] public enum GooglePhotosContentCategoryType { ANIMALS, @@ -132,15 +130,15 @@ public Filter(DateTime startDate, DateTime endDate) { dateFilter = new dateFilter { - ranges = new[] { new gDateRange { startDate = new gDate(startDate), endDate = new gDate(endDate) } } + ranges = [new() { startDate = new gDate(startDate), endDate = new gDate(endDate) }] }; } - public Filter(GooglePhotosContentCategoryType category) => this.contentFilter = new contentFilter { includedContentCategories = new[] { category } }; + public Filter(GooglePhotosContentCategoryType category) => contentFilter = new contentFilter { includedContentCategories = [category] }; - public Filter(GooglePhotosContentCategoryType[] categories) => this.contentFilter = new contentFilter { includedContentCategories = categories }; + public Filter(GooglePhotosContentCategoryType[] categories) => contentFilter = new contentFilter { includedContentCategories = categories }; - public Filter(List categories) => this.contentFilter = new contentFilter { includedContentCategories = categories.ToArray() }; + public Filter(List categories) => contentFilter = new contentFilter { includedContentCategories = categories.ToArray() }; public contentFilter? contentFilter { get; set; } public dateFilter? dateFilter { get; set; } @@ -201,6 +199,7 @@ public class gDateRange public gDate endDate { get; set; } = default!; } +[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public class Album { /// @@ -236,7 +235,7 @@ public class Album /// /// [Output only] The number of media items in the album. /// - public int mediaItemsCount { get; set; } + public long? mediaItemsCount { get; set; } /// /// [Output only] Information related to shared albums.This field is only populated if the album is a shared album, the developer created the album and the user has granted the photoslibrary.sharing scope. @@ -442,100 +441,72 @@ public class EnrichmentItem /// Text for this enrichment item. /// https://developers.google.com/photos/library/reference/rest/v1/albums/addEnrichment#textenrichment /// -public class TextEnrichment +public class TextEnrichment(string text) { - public TextEnrichment(string text) - { - this.text = text; - } - - public string text { get; set; } + public string text { get; set; } = text; } /// /// An enrichment containing a single location. /// https://developers.google.com/photos/library/reference/rest/v1/albums/addEnrichment#locationenrichment /// -public class LocationEnrichment +public class LocationEnrichment(Location location) { - public LocationEnrichment(Location location) - { - this.location = location; - } - /// /// Location for this enrichment item. /// - public Location location { get; set; } + public Location location { get; set; } = location; } /// /// Represents a physical location. /// https://developers.google.com/photos/library/reference/rest/v1/albums/addEnrichment#location /// -public class Location +public class Location(string locationName, Latlng latlng) { - public Location(string locationName, Latlng latlng) - { - this.locationName = locationName; - this.latlng = latlng; - } - /// /// Name of the location to be displayed. /// - public string locationName { get; set; } + public string locationName { get; set; } = locationName; /// /// Position of the location on the map. /// - public Latlng latlng { get; set; } + public Latlng latlng { get; set; } = latlng; } /// /// An object representing a latitude/longitude pair. This is expressed as a pair of doubles representing degrees latitude and degrees longitude. Unless specified otherwise, this must conform to the WGS84 standard. Values must be within normalized ranges. /// https://developers.google.com/photos/library/reference/rest/v1/albums/addEnrichment#latlng /// -public class Latlng +public class Latlng(double latitude, double longitude) { - public Latlng(double latitude, double longitude) - { - this.latitude = latitude; - this.longitude = longitude; - } - /// /// The latitude in degrees. It must be in the range [-90.0, +90.0]. /// - public double latitude { get; set; } + public double latitude { get; set; } = latitude; /// /// The longitude in degrees. It must be in the range [-180.0, +180.0]. /// - public double longitude { get; set; } + public double longitude { get; set; } = longitude; } /// /// An enrichment containing a map, showing origin and destination locations. /// https://developers.google.com/photos/library/reference/rest/v1/albums/addEnrichment#mapenrichment /// -public class MapEnrichment +public class MapEnrichment(Location origin, Location destination) { - public MapEnrichment(Location origin, Location destination) - { - this.origin = origin; - this.destination = destination; - } - /// /// Origin location for this enrichment item. /// - public Location origin { get; set; } + public Location origin { get; set; } = origin; /// /// Destination location for this enrichment item. /// - public Location destination { get; set; } + public Location destination { get; set; } = destination; } #endregion diff --git a/src/CasCap.Apis.GooglePhotos/Models/GooglePhotosOptions.cs b/src/CasCap.Api.GooglePhotos/Models/GooglePhotosOptions.cs similarity index 91% rename from src/CasCap.Apis.GooglePhotos/Models/GooglePhotosOptions.cs rename to src/CasCap.Api.GooglePhotos/Models/GooglePhotosOptions.cs index 8f15f50..498c90c 100644 --- a/src/CasCap.Apis.GooglePhotos/Models/GooglePhotosOptions.cs +++ b/src/CasCap.Api.GooglePhotos/Models/GooglePhotosOptions.cs @@ -3,6 +3,11 @@ [Serializable] public class GooglePhotosOptions { + /// + /// Configuration sub-section locator key. + /// + public const string SectionKey = $"{nameof(CasCap)}:{nameof(GooglePhotosOptions)}"; + /// /// The default endpoint for REST API requests, currently defaults to REST API v1.0 /// diff --git a/src/CasCap.Api.GooglePhotos/Models/PagingEventArgs.cs b/src/CasCap.Api.GooglePhotos/Models/PagingEventArgs.cs new file mode 100644 index 0000000..9ebc53f --- /dev/null +++ b/src/CasCap.Api.GooglePhotos/Models/PagingEventArgs.cs @@ -0,0 +1,10 @@ +namespace CasCap.Models; + +public class PagingEventArgs(int pageSize, int pageNumber, int recordCount) : EventArgs +{ + public int pageSize { get; } = pageSize; + public int pageNumber { get; } = pageNumber; + public int recordCount { get; } = recordCount; + public DateTime? minDate { get; set; } + public DateTime? maxDate { get; set; } +} diff --git a/src/CasCap.Apis.GooglePhotos/Models/RequestUris.cs b/src/CasCap.Api.GooglePhotos/Models/RequestUris.cs similarity index 100% rename from src/CasCap.Apis.GooglePhotos/Models/RequestUris.cs rename to src/CasCap.Api.GooglePhotos/Models/RequestUris.cs diff --git a/src/CasCap.Api.GooglePhotos/Models/UploadProgressEventArgs.cs b/src/CasCap.Api.GooglePhotos/Models/UploadProgressEventArgs.cs new file mode 100644 index 0000000..313a6dc --- /dev/null +++ b/src/CasCap.Api.GooglePhotos/Models/UploadProgressEventArgs.cs @@ -0,0 +1,10 @@ +namespace CasCap.Models; + +public class UploadProgressEventArgs(string fileName, long totalBytes, int batchIndex, long uploadedBytes, long batchSize) : EventArgs +{ + public string fileName { get; } = fileName; + public long totalBytes { get; } = totalBytes; + public long batchIndex { get; } = batchIndex; + public long uploadedBytes { get; } = uploadedBytes; + public long batchSize { get; } = batchSize; +} diff --git a/src/CasCap.Apis.GooglePhotos/Services/Base/GooglePhotosServiceBase.cs b/src/CasCap.Api.GooglePhotos/Services/Base/GooglePhotosServiceBase.cs similarity index 93% rename from src/CasCap.Apis.GooglePhotos/Services/Base/GooglePhotosServiceBase.cs rename to src/CasCap.Api.GooglePhotos/Services/Base/GooglePhotosServiceBase.cs index d6ef6b6..d0fd3a2 100644 --- a/src/CasCap.Apis.GooglePhotos/Services/Base/GooglePhotosServiceBase.cs +++ b/src/CasCap.Api.GooglePhotos/Services/Base/GooglePhotosServiceBase.cs @@ -26,7 +26,7 @@ public abstract class GooglePhotosServiceBase : HttpClientBase const int defaultBatchSizeMediaItems = 50; - GooglePhotosOptions? _options; + GooglePhotosOptions _options; public GooglePhotosServiceBase(ILogger logger, IOptions options, @@ -34,15 +34,15 @@ HttpClient client ) { _logger = logger; - _options = options.Value;// ?? throw new ArgumentNullException(nameof(options), $"{nameof(GooglePhotosOptions)} cannot be null!"); + _options = options.Value; _client = client ?? throw new ArgumentNullException(nameof(client), $"{nameof(HttpClient)} cannot be null!"); } protected virtual void RaisePagingEvent(PagingEventArgs args) => PagingEvent?.Invoke(this, args); public event EventHandler? PagingEvent; - protected virtual void RaiseUploadProgressEvent(UploadProgressArgs args) => UploadProgressEvent?.Invoke(this, args); - public event EventHandler? UploadProgressEvent; + protected virtual void RaiseUploadProgressEvent(UploadProgressEventArgs args) => UploadProgressEvent?.Invoke(this, args); + public event EventHandler? UploadProgressEvent; public static bool IsFileUploadable(string path) => IsFileUploadableByExtension(Path.GetExtension(path)); @@ -105,7 +105,7 @@ public static bool IsFileUploadableByExtension(string extension) { GooglePhotosScope.Sharing, "https://www.googleapis.com/auth/photoslibrary.sharing" } }; - public async Task LoginAsync(string User, string ClientId, string ClientSecret, GooglePhotosScope[] Scopes, string? FileDataStoreFullPathOverride = null) + public async Task LoginAsync(string User, string ClientId, string ClientSecret, GooglePhotosScope[] Scopes, string? FileDataStoreFullPathOverride = null, CancellationToken cancellationToken = default) { _options = new GooglePhotosOptions { @@ -115,22 +115,21 @@ public async Task LoginAsync(string User, string ClientId, string ClientSe Scopes = Scopes, FileDataStoreFullPathOverride = FileDataStoreFullPathOverride }; - return await LoginAsync(); + return await LoginAsync(cancellationToken); } - public async Task LoginAsync(GooglePhotosOptions options) + public async Task LoginAsync(GooglePhotosOptions options, CancellationToken cancellationToken = default) { _options = options ?? throw new ArgumentNullException(nameof(options), $"{nameof(GooglePhotosOptions)} cannot be null!"); - return await LoginAsync(); + return await LoginAsync(cancellationToken); } - public async Task LoginAsync() + public async Task LoginAsync(CancellationToken cancellationToken = default) { - if (_options is null) throw new ArgumentNullException(nameof(_options), $"{nameof(GooglePhotosOptions)}.{nameof(_options)} cannot be null!"); - if (string.IsNullOrWhiteSpace(_options.User)) throw new ArgumentNullException(nameof(_options.User), $"{nameof(GooglePhotosOptions)}.{nameof(_options.User)} cannot be null!"); - if (string.IsNullOrWhiteSpace(_options.ClientId)) throw new ArgumentNullException(nameof(_options.ClientId), $"{nameof(GooglePhotosOptions)}.{nameof(_options.ClientId)} cannot be null!"); - if (string.IsNullOrWhiteSpace(_options.ClientSecret)) throw new ArgumentNullException(nameof(_options.ClientSecret), $"{nameof(GooglePhotosOptions)}.{nameof(_options.ClientSecret)} cannot be null!"); - if (_options.Scopes.IsNullOrEmpty()) throw new ArgumentNullException(nameof(_options.Scopes), $"{nameof(GooglePhotosOptions)}.{nameof(_options.Scopes)} cannot be null/empty!"); + if (string.IsNullOrWhiteSpace(_options.User)) throw new GooglePhotosException($"{nameof(GooglePhotosOptions)}.{nameof(_options.User)} cannot be null!"); + if (string.IsNullOrWhiteSpace(_options.ClientId)) throw new GooglePhotosException($"{nameof(GooglePhotosOptions)}.{nameof(_options.ClientId)} cannot be null!"); + if (string.IsNullOrWhiteSpace(_options.ClientSecret)) throw new GooglePhotosException($"{nameof(GooglePhotosOptions)}.{nameof(_options.ClientSecret)} cannot be null!"); + if (_options.Scopes.IsNullOrEmpty()) throw new GooglePhotosException($"{nameof(GooglePhotosOptions)}.{nameof(_options.Scopes)} cannot be null/empty!"); var secrets = new ClientSecrets { ClientId = _options.ClientId, ClientSecret = _options.ClientSecret }; @@ -143,10 +142,10 @@ public async Task LoginAsync() secrets, GetScopes(), _options.User, - CancellationToken.None, + cancellationToken, dataStore); - _logger.LogDebug("Authorisation granted or not required (if the saved access token already available)"); + _logger.LogDebug("Authorization granted or not required (if the saved access token already available)"); if (credential.Token.IsStale) { @@ -161,7 +160,7 @@ public async Task LoginAsync() } else _logger.LogDebug("The access token is OK, continue"); - _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(credential.Token.TokenType, credential.Token.AccessToken); + SetAuth(credential.Token.TokenType, credential.Token.AccessToken); return true; string[] GetScopes()//todo: make extension method to convert any enum to string[] and move to CasCap.Common.Extensions @@ -174,6 +173,14 @@ string[] GetScopes()//todo: make extension method to convert any enum to string[ } } + /// + /// Workaround to allow setting the auth header when running integration tests from CI. + /// + /// + /// + public void SetAuth(string tokenType, string accessToken) + => _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(tokenType, accessToken); + #region https://photoslibrary.googleapis.com/v1/albums //https://photoslibrary.googleapis.com/v1/albums/{albumId} @@ -224,8 +231,8 @@ public Task> GetSharedAlbumsAsync(int pageSize = defaultPageSizeAlbu else if (tpl.result is not null)//to hide nullability warning { var batch = new List(pageSize); - if (!tpl.result.albums.IsNullOrEmpty()) batch = tpl.result.albums ?? new List(); - if (!tpl.result.sharedAlbums.IsNullOrEmpty()) batch = tpl.result.sharedAlbums ?? new List(); + if (!tpl.result.albums.IsNullOrEmpty()) batch = tpl.result.albums ?? []; + if (!tpl.result.sharedAlbums.IsNullOrEmpty()) batch = tpl.result.sharedAlbums ?? []; l.AddRange(batch); if (!string.IsNullOrWhiteSpace(tpl.result.nextPageToken)) RaisePagingEvent(new PagingEventArgs(batch.Count, pageNumber, l.Count)); @@ -332,7 +339,7 @@ async IAsyncEnumerable _GetMediaItemsAsync(int pageSize, int maxPageC if (pageSize < minPageSizeMediaItems || pageSize > maxPageSizeMediaItems) throw new ArgumentOutOfRangeException($"{nameof(pageSize)} must be between {minPageSizeMediaItems} and {maxPageSizeMediaItems}!"); - //Note: mediaitem results are not garuanteed to be unique so we check returned ids in a volatile hashset + //Note: MediaItem results are not guaranteed to be unique so we check returned ids in a volatile HashSet var hs = new HashSet(); var pageToken = string.Empty; var pageNumber = 1; @@ -344,14 +351,14 @@ async IAsyncEnumerable _GetMediaItemsAsync(int pageSize, int maxPageC else if (tpl.result is not null) { var batch = new List(pageSize); - if (!tpl.result.mediaItems.IsNullOrEmpty()) batch = tpl.result.mediaItems ?? new List(); + if (!tpl.result.mediaItems.IsNullOrEmpty()) batch = tpl.result.mediaItems ?? []; foreach (var mi in batch) if (!hs.Contains(mi.id)) { hs.Add(mi.id); yield return mi; } - if (!string.IsNullOrWhiteSpace(tpl.result.nextPageToken) && batch.Any()) + if (!string.IsNullOrWhiteSpace(tpl.result.nextPageToken) && batch.Count != 0) { //Note: low page sizes can return 0 records but still return a continuation token, weirdness RaisePagingEvent(new PagingEventArgs(batch.Count, pageNumber, hs.Count) @@ -388,14 +395,14 @@ async IAsyncEnumerable _GetMediaItemsViaPOSTAsync(string? albumId, in else if (tpl.result is not null) { var batch = new List(pageSize); - if (!tpl.result.mediaItems.IsNullOrEmpty()) batch = tpl.result.mediaItems ?? new List(); + if (!tpl.result.mediaItems.IsNullOrEmpty()) batch = tpl.result.mediaItems ?? []; foreach (var mi in batch) if (!hs.Contains(mi.id)) { hs.Add(mi.id); yield return mi; } - if (!string.IsNullOrWhiteSpace(tpl.result.nextPageToken) && batch.Any()) + if (!string.IsNullOrWhiteSpace(tpl.result.nextPageToken) && batch.Count != 0) RaisePagingEvent(new PagingEventArgs(batch.Count, pageNumber, hs.Count) { minDate = batch.Min(p => p.mediaMetadata.creationTime), @@ -433,6 +440,7 @@ public async IAsyncEnumerable GetMediaItemsByIdsAsync(List me var batches = mediaItemIds.GetBatches(defaultBatchSizeMediaItems); foreach (var batch in batches) { + if (cancellationToken.IsCancellationRequested) break; //see https://github.com/dotnet/aspnetcore/issues/7945 can't use QueryHelpers.AddQueryString //var queryParams = new Dictionary(batch.Value.Length); //foreach (var mediaItemId in batch.Value) @@ -597,7 +605,7 @@ IAsyncEnumerable _GetMediaItemsByFilterAsync(Filter filter, int maxPa if (!File.Exists(path)) throw new FileNotFoundException($"can't find '{path}'"); var size = new FileInfo(path).Length; - if (size < 1) throw new Exception($"media file {path} has no data?"); + if (size < 1) throw new GooglePhotosException($"media file {path} has no data?"); if (IsImage(Path.GetExtension(path)) && size > maxSizeImageBytes) throw new NotSupportedException($"Media file {path} is too big for known upload limits of {maxSizeImageBytes} bytes!"); if (IsVideo(Path.GetExtension(path)) && size > maxSizeVideoBytes) @@ -613,7 +621,7 @@ IAsyncEnumerable _GetMediaItemsByFilterAsync(Filter filter, int maxPa { headers.Add((X_Goog_Upload_Command, "start")); var fileName = Path.GetFileName(path); - //Note: UrlPathEncode below is not intended to be used... but fixes https://github.com/f2calv/CasCap.Apis.GooglePhotos/issues/110 + //Note: UrlPathEncode below is not intended to be used... but fixes https://github.com/f2calv/CasCap.Api.GooglePhotos/issues/110 headers.Add((X_Goog_Upload_File_Name, HttpUtility.UrlPathEncode(fileName))); headers.Add((X_Goog_Upload_Protocol, "resumable")); headers.Add((X_Goog_Upload_Raw_Size, size.ToString())); @@ -622,22 +630,22 @@ IAsyncEnumerable _GetMediaItemsByFilterAsync(Filter filter, int maxPa if (uploadMethod == GooglePhotosUploadMethod.Simple) { var bytes = File.ReadAllBytes(path); - var tpl = await PostBytes(RequestUris.uploads, uploadMethod == GooglePhotosUploadMethod.ResumableSingle ? Array.Empty() : bytes, headers: headers); + var tpl = await PostBytes(RequestUris.uploads, uploadMethod == GooglePhotosUploadMethod.ResumableSingle ? [] : bytes, headers: headers); if (tpl.error is not null) throw new GooglePhotosException(tpl.error); return tpl.result; } else { - var tpl = await PostBytes(RequestUris.uploads, Array.Empty(), headers: headers); + var tpl = await PostBytes(RequestUris.uploads, [], headers: headers); var status = tpl.responseHeaders.TryGetValue(X_Goog_Upload_Status); - var Upload_URL = tpl.responseHeaders.TryGetValue(X_Goog_Upload_URL) ?? throw new Exception($"{nameof(X_Goog_Upload_URL)}"); + var Upload_URL = tpl.responseHeaders.TryGetValue(X_Goog_Upload_URL) ?? throw new GooglePhotosException($"{nameof(X_Goog_Upload_URL)}"); //Debug.WriteLine($"{Upload_URL}={Upload_URL}"); var sUpload_Chunk_Granularity = tpl.responseHeaders.TryGetValue(X_Goog_Upload_Chunk_Granularity); if (int.TryParse(sUpload_Chunk_Granularity, out var Upload_Chunk_Granularity) && Upload_Chunk_Granularity <= 0) - throw new Exception($"invalid {X_Goog_Upload_Chunk_Granularity}!"); + throw new GooglePhotosException($"invalid {X_Goog_Upload_Chunk_Granularity}!"); - headers = new List<(string name, string value)>(); + headers = []; if (uploadMethod == GooglePhotosUploadMethod.ResumableSingle) { @@ -650,10 +658,10 @@ IAsyncEnumerable _GetMediaItemsByFilterAsync(Filter filter, int maxPa if (tpl.httpStatusCode != HttpStatusCode.OK) { //we were interrupted so query the status of the last upload - headers = new List<(string name, string value)> - { + headers = + [ (X_Goog_Upload_Command, "query") - }; + ]; tpl = await PostBytes(Upload_URL, bytes, headers: headers); if (tpl.error is not null) throw new GooglePhotosException(tpl.error); @@ -682,11 +690,11 @@ IAsyncEnumerable _GetMediaItemsByFilterAsync(Filter filter, int maxPa //var lastChunk = offset + Upload_Chunk_Granularity >= size; var lastChunk = batchIndex + 1 == batchCount; - headers = new List<(string name, string value)> - { + headers = + [ (X_Goog_Upload_Command, $"upload{(lastChunk ? ", finalize" : string.Empty)}"), (X_Goog_Upload_Offset, offset.ToString()) - }; + ]; //todo: need to test resuming failed uploads var bytes = reader.ReadBytes(Upload_Chunk_Granularity); @@ -696,12 +704,12 @@ IAsyncEnumerable _GetMediaItemsByFilterAsync(Filter filter, int maxPa if (tpl.httpStatusCode != HttpStatusCode.OK) { //we were interrupted so query the status of the last upload - headers = new List<(string name, string value)> - { + headers = + [ (X_Goog_Upload_Command, "query") - }; + ]; _logger.LogDebug($""); - tpl = await PostBytes(Upload_URL, Array.Empty(), headers: headers); + tpl = await PostBytes(Upload_URL, [], headers: headers); status = tpl.responseHeaders.TryGetValue(X_Goog_Upload_Status); _logger.LogTrace("{methodName}, status={status}", nameof(UploadMediaAsync), status); @@ -713,7 +721,7 @@ IAsyncEnumerable _GetMediaItemsByFilterAsync(Filter filter, int maxPa { attemptCount = 0;//reset retry count offset += bytes.Length; - RaiseUploadProgressEvent(new UploadProgressArgs(Path.GetFileName(path), size, batchIndex, offset, bytes.Length)); + RaiseUploadProgressEvent(new UploadProgressEventArgs(Path.GetFileName(path), size, batchIndex, offset, bytes.Length)); batchIndex++; //if (callback is not null) // callback(bytes.Length); diff --git a/src/CasCap.Apis.GooglePhotos/Services/GooglePhotosService.cs b/src/CasCap.Api.GooglePhotos/Services/GooglePhotosService.cs similarity index 94% rename from src/CasCap.Apis.GooglePhotos/Services/GooglePhotosService.cs rename to src/CasCap.Api.GooglePhotos/Services/GooglePhotosService.cs index c7eb13f..d8fd8f8 100644 --- a/src/CasCap.Apis.GooglePhotos/Services/GooglePhotosService.cs +++ b/src/CasCap.Api.GooglePhotos/Services/GooglePhotosService.cs @@ -5,16 +5,9 @@ /// //https://developers.google.com/photos/library/guides/get-started //https://developers.google.com/photos/library/guides/authentication-authorization -public class GooglePhotosService : GooglePhotosServiceBase +public class GooglePhotosService(ILogger logger, IOptions options, HttpClient client) + : GooglePhotosServiceBase(logger, options, client) { - public GooglePhotosService(ILogger logger, - IOptions options, - HttpClient client - ) : base(logger, options, client) - { - - } - public async Task GetOrCreateAlbumAsync(string title, StringComparison comparisonType = StringComparison.OrdinalIgnoreCase) { var album = await GetAlbumByTitleAsync(title, comparisonType); diff --git a/src/CasCap.Apis.GooglePhotos/Usings.cs b/src/CasCap.Api.GooglePhotos/Usings.cs similarity index 67% rename from src/CasCap.Apis.GooglePhotos/Usings.cs rename to src/CasCap.Api.GooglePhotos/Usings.cs index 234333a..e3d3a82 100644 --- a/src/CasCap.Apis.GooglePhotos/Usings.cs +++ b/src/CasCap.Api.GooglePhotos/Usings.cs @@ -1,9 +1,10 @@ -global using CasCap.Common.Extensions; +global using CasCap.Abstractions; +global using CasCap.Common.Extensions; global using CasCap.Exceptions; -global using CasCap.Interfaces; global using CasCap.Messages; global using CasCap.Models; global using CasCap.Services; global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Options; +global using System.Text.Json.Serialization; diff --git a/src/CasCap.Apis.GooglePhotos.Tests/Tests/ExifTests.cs b/src/CasCap.Apis.GooglePhotos.Tests/Tests/ExifTests.cs deleted file mode 100644 index fe01b77..0000000 --- a/src/CasCap.Apis.GooglePhotos.Tests/Tests/ExifTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -using CasCap.Models; -using CasCap.Services; -using CasCap.Xunit; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Metadata.Profiles.Exif; -using System.Diagnostics; -using Xunit; -using Xunit.Abstractions; - -namespace CasCap.Apis.GooglePhotos.Tests; - -public class ExifTests : TestBase -{ - public ExifTests(ITestOutputHelper output) : base(output) { } - - /// - /// Minimal exif tags added by Google. - /// - const int googleExifTagCount = 5; - - [SkipIfCIBuildTheory, Trait("Type", nameof(GooglePhotosService))] - [InlineData("test11.jpg", 55.041388888888889d, 8.4677777777777781d, 62)] - public async Task CheckExifData(string fileName, double latitude, double longitude, int exifTagCount) - { - var path = $"{_testFolder}{fileName}"; - var originalBytes = File.ReadAllBytes(path); - - var loginResult = await _googlePhotosSvc.LoginAsync(); - Assert.True(loginResult); - - var tplOriginal = await ExifTests.GetExifInfo(path); - Assert.Equal(latitude, tplOriginal.latitude); - Assert.Equal(longitude, tplOriginal.longitude); - Assert.Equal(exifTagCount, tplOriginal.exifTagCount); - - var uploadToken = await _googlePhotosSvc.UploadMediaAsync(path, GooglePhotosUploadMethod.Simple); - Assert.NotNull(uploadToken); - var newMediaItemResult = await _googlePhotosSvc.AddMediaItemAsync(uploadToken, path); - Assert.NotNull(newMediaItemResult); - //the upload returns a null baseUrl - Assert.Null(newMediaItemResult.mediaItem.baseUrl); - - //so now retrieve all media items - var mediaItems = await _googlePhotosSvc.GetMediaItemsAsync().ToListAsync(); - - var uploadedMediaItem = mediaItems.FirstOrDefault(p => p.filename.Equals(fileName)); - Assert.NotNull(uploadedMediaItem); - Assert.True(uploadedMediaItem.isPhoto); - - var bytesNoExif = await _googlePhotosSvc.DownloadBytes(uploadedMediaItem, includeExifMetadata: false); - Assert.NotNull(bytesNoExif); - var tplNoExif = await ExifTests.GetExifInfo(bytesNoExif); - Assert.True(googleExifTagCount == tplNoExif.exifTagCount); - - var bytesWithExif = await _googlePhotosSvc.DownloadBytes(uploadedMediaItem, includeExifMetadata: true); - Assert.NotNull(bytesWithExif); - var tplWithExif = await ExifTests.GetExifInfo(bytesWithExif); - Assert.Null(tplWithExif.latitude);//location exif data always stripped :( - Assert.Null(tplWithExif.longitude);//location exif data always stripped :( - Assert.True(tplOriginal.exifTagCount > tplWithExif.exifTagCount);//due to Google-stripping fewer exif tags are returned - Assert.True(googleExifTagCount < tplWithExif.exifTagCount); - } - - static async Task<(double? latitude, double? longitude, int exifTagCount)> GetExifInfo(string path) - { - using var image = await Image.LoadAsync(path); - return GetLatLong(image); - } - - static async Task<(double? latitude, double? longitude, int exifTagCount)> GetExifInfo(byte[] bytes) - { - var stream = new MemoryStream(bytes); - using var image = await Image.LoadAsync(stream); - return GetLatLong(image); - } - - static (double? latitude, double? longitude, int exifTagCount) GetLatLong(Image image) - { - double? latitude = null, longitude = null; - var exifTagCount = image.Metadata.ExifProfile?.Values.Count ?? 0; - if (image.Metadata.ExifProfile.Values?.Any() ?? false) - { - var exifData = image.Metadata.ExifProfile; - if (exifData != null) - { - if (exifData.TryGetValue(ExifTag.GPSLatitude, out var gpsLatitude) - && exifData.TryGetValue(ExifTag.GPSLatitudeRef, out var gpsLatitudeRef)) - latitude = GetCoordinates(gpsLatitudeRef.ToString(), gpsLatitude.Value); - - if (exifData.TryGetValue(ExifTag.GPSLongitude, out var gpsLong) - && exifData.TryGetValue(ExifTag.GPSLongitudeRef, out var gpsLongRef)) - longitude = GetCoordinates(gpsLongRef.ToString(), gpsLong.Value); - - Debug.WriteLine($"latitude,longitude = {latitude},{longitude}"); - } - } - - return (latitude, longitude, exifTagCount); - } - - static double GetCoordinates(string gpsRef, Rational[] rationals) - { - var degrees = rationals[0].Numerator / rationals[0].Denominator; - var minutes = rationals[1].Numerator / rationals[1].Denominator; - var seconds = rationals[2].Numerator / rationals[2].Denominator; - - var coordinate = degrees + (minutes / 60d) + (seconds / 3600d); - if (gpsRef == "S" || gpsRef == "W") - coordinate *= -1; - return coordinate; - } -} diff --git a/src/CasCap.Apis.GooglePhotos/Exceptions/GooglePhotosException.cs b/src/CasCap.Apis.GooglePhotos/Exceptions/GooglePhotosException.cs deleted file mode 100644 index 2c625ed..0000000 --- a/src/CasCap.Apis.GooglePhotos/Exceptions/GooglePhotosException.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CasCap.Exceptions; - -public class GooglePhotosException : Exception -{ - public GooglePhotosException() { } - - public GooglePhotosException(Error error) - : base(error is not null && error.error is not null && error.error.message is not null ? error.error.message : "unknown") - { - } -} diff --git a/src/CasCap.Apis.GooglePhotos/Extensions/DI.cs b/src/CasCap.Apis.GooglePhotos/Extensions/DI.cs deleted file mode 100644 index bebfdc9..0000000 --- a/src/CasCap.Apis.GooglePhotos/Extensions/DI.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Polly; -using System.Net; -using System.Net.Http.Headers; -namespace Microsoft.Extensions.DependencyInjection; - -public static class DI -{ - public static void AddGooglePhotos(this IServiceCollection services) - => services.AddGooglePhotos(_ => { }); - - static readonly string sectionKey = $"{nameof(CasCap)}:{nameof(GooglePhotosOptions)}"; - - public static void AddGooglePhotos(this IServiceCollection services, Action configure) - { - services.AddSingleton>(s => - { - var configuration = s.GetService(); - return new ConfigureOptions(options => configuration?.Bind(sectionKey, options)); - }); - services.AddHttpClient((s, client) => - { - var configuration = s.GetRequiredService(); - var options = configuration.GetSection(sectionKey).Get(); - options ??= new GooglePhotosOptions();//we use default BaseAddress if no config object injected in - client.BaseAddress = new Uri(options.BaseAddress); - client.DefaultRequestHeaders.Add("User-Agent", $"{nameof(CasCap)}.{AppDomain.CurrentDomain.FriendlyName}.{Environment.MachineName}"); - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip")); - client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("deflate")); - client.Timeout = Timeout.InfiniteTimeSpan; - }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler - { - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate - }) - .AddTransientHttpErrorPolicy(policyBuilder => - { - return policyBuilder.RetryAsync(retryCount: 3); - }) - //https://github.com/aspnet/AspNetCore/issues/6804 - .SetHandlerLifetime(Timeout.InfiniteTimeSpan); - } -} diff --git a/src/CasCap.Apis.GooglePhotos/Models/PagingEventArgs.cs b/src/CasCap.Apis.GooglePhotos/Models/PagingEventArgs.cs deleted file mode 100644 index 10228ea..0000000 --- a/src/CasCap.Apis.GooglePhotos/Models/PagingEventArgs.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CasCap.Models; - -public class PagingEventArgs : EventArgs -{ - public PagingEventArgs(int pageSize, int pageNumber, int recordCount) - { - this.pageSize = pageSize; - this.pageNumber = pageNumber; - this.recordCount = recordCount; - } - - public int pageSize { get; } - public int pageNumber { get; } - public int recordCount { get; } - public DateTime? minDate { get; set; } - public DateTime? maxDate { get; set; } -} diff --git a/src/CasCap.Apis.GooglePhotos/Models/UploadProgressEventArgs.cs b/src/CasCap.Apis.GooglePhotos/Models/UploadProgressEventArgs.cs deleted file mode 100644 index dc6e323..0000000 --- a/src/CasCap.Apis.GooglePhotos/Models/UploadProgressEventArgs.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace CasCap.Models; - -public class UploadProgressArgs : EventArgs -{ - public UploadProgressArgs(string fileName, long totalBytes, int batchIndex, long uploadedBytes, long batchSize) - { - this.fileName = fileName; - this.totalBytes = totalBytes; - this.batchIndex = batchIndex; - this.uploadedBytes = uploadedBytes; - this.batchSize = batchSize; - } - - public string fileName { get; } - public long totalBytes { get; } - public long batchIndex { get; } - public long uploadedBytes { get; } - public long batchSize { get; } -}