Compare commits

..

1 Commits

Author SHA1 Message Date
Claude 2feee111fd feat(mobile): choose JPEG preview or original quality when sharing
Add a mechanism to share either the server-generated JPEG preview or the
full original file. When the selection contains at least one asset that can
provide a preview (a remote image), a bottom sheet lets the user pick the
quality; otherwise sharing falls back to the original as before.

The share-source resolution is extracted into pure, unit-tested business
logic that handles all three asset states (local, remote, merged):
- preview requires a remote image; videos and local-only assets fall back
  to the original
- original prefers the on-device file, but edited assets are downloaded
  since edits only exist on the server
- preview filenames are normalized to a .jpg extension

Add unit tests covering the resolution, gating and filename helpers.
2026-05-30 18:39:01 +00:00
191 changed files with 2281 additions and 6696 deletions
@@ -15,7 +15,7 @@ services:
volumes:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
- build_cache:/buildcache
- pnpm_store_server:/buildcache/pnpm-store
- ../packages/plugin-core:/build/plugins/immich-plugin-core
immich-web:
env_file: !reset []
+1
View File
@@ -0,0 +1 @@
custom: ['https://buy.immich.app', 'https://immich.store']
+3 -3
View File
@@ -51,7 +51,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -79,7 +79,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -201,7 +201,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
actions: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@50e6a3413e5aa9c3ae4d8393c34745be44288b46 # v0.0.48
uses: oasdiff/oasdiff-action/breaking@6147a58e5d1249a12f42fc864ab791d571a30015 # v0.0.47
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
+9 -11
View File
@@ -31,7 +31,7 @@ jobs:
working-directory: ./packages/cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -49,9 +49,7 @@ jobs:
- name: Publish
if: ${{ github.event_name == 'release' }}
env:
NPM_TAG: ${{ github.event.release.prerelease && 'rc' || 'latest' }}
run: mise run ci-publish -- --tag "$NPM_TAG"
run: mise run ci-publish
docker:
name: Docker
@@ -63,7 +61,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -75,13 +73,13 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
@@ -96,7 +94,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
flavor: |
latest=false
@@ -104,10 +102,10 @@ jobs:
name=ghcr.io/${{ github.repository_owner }}/immich-cli
tags: |
type=raw,value=${{ steps.package-version.outputs.version }},enable=${{ github.event_name == 'release' }}
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
file: packages/cli/Dockerfile
platforms: linux/amd64,linux/arm64
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:e73f60195b39748c4876f23e3e6cd22a68a9754acec8aef1fd6979fd52cd2c9f
image: ghcr.io/immich-app/mdq:main@sha256:0a8b8867773a0f8368061f47578603f438349f8f1f28b0e16105f481e5c794e0
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:
+4 -4
View File
@@ -44,7 +44,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
category: '/language:${{matrix.language}}'
+7 -7
View File
@@ -23,7 +23,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "pokedex-large"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@db54dcf16fbb12c43479a23749ceea0ad1b4a704 # multi-runner-build-workflow-v3.0.0
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@5813c7c4f7016c748ae7ac5d5f684846649d4d20 # multi-runner-build-workflow-v2.4.0
permissions:
contents: read
actions: read
@@ -147,7 +147,7 @@ jobs:
platforms: ${{ matrix.platforms }}
runner-mapping: ${{ matrix.runner-mapping }}
suffixes: ${{ matrix.suffixes }}
dockerhub-push: ${{ github.event_name == 'release' && !github.event.release.prerelease }}
dockerhub-push: ${{ github.event_name == 'release' }}
build-args: |
DEVICE=${{ matrix.device }}
@@ -155,7 +155,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@db54dcf16fbb12c43479a23749ceea0ad1b4a704 # multi-runner-build-workflow-v3.0.0
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@5813c7c4f7016c748ae7ac5d5f684846649d4d20 # multi-runner-build-workflow-v2.4.0
permissions:
contents: read
actions: read
@@ -167,7 +167,7 @@ jobs:
image: immich-server
context: .
dockerfile: server/Dockerfile
dockerhub-push: ${{ github.event_name == 'release' && !github.event.release.prerelease }}
dockerhub-push: ${{ github.event_name == 'release' }}
build-args: |
DEVICE=cpu
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -54,7 +54,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+4 -12
View File
@@ -20,7 +20,7 @@ jobs:
artifact: ${{ steps.get-artifact.outputs.result }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -98,16 +98,9 @@ jobs:
shouldDeploy: true
};
} else if (eventType == "release") {
const tag = context.payload.workflow_run.head_branch;
const { data: release } = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag,
});
parameters = {
event: "release",
name: tag,
prerelease: release.prerelease,
name: context.payload.workflow_run.head_branch,
shouldDeploy: !isFork
};
}
@@ -126,7 +119,7 @@ jobs:
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -153,7 +146,6 @@ jobs:
const parameters = JSON.parse(process.env.PARAM_JSON);
core.setOutput("event", parameters.event);
core.setOutput("name", parameters.name);
core.setOutput("prerelease", parameters.prerelease);
core.setOutput("shouldDeploy", parameters.shouldDeploy);
- name: Download artifact
@@ -211,7 +203,7 @@ jobs:
run: mise run //docs:deploy
- name: Deploy Docs Release Domain
if: ${{ steps.parameters.outputs.event == 'release' && steps.parameters.outputs.prerelease != 'true' }}
if: ${{ steps.parameters.outputs.event == 'release' }}
env:
TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -2
View File
@@ -49,7 +49,7 @@ jobs:
permissions: {} # No job-level permissions are needed because it uses the app-token
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -137,7 +137,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
draft: true
tag_name: ${{ needs.bump_version.outputs.version }}
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -32,7 +32,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -4
View File
@@ -16,7 +16,7 @@ jobs:
packages: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -39,6 +39,4 @@ jobs:
run: pnpm --filter @immich/sdk build
- name: Publish
env:
NPM_TAG: ${{ github.event.release.prerelease && 'rc' || 'latest' }}
run: pnpm --filter @immich/sdk publish --provenance --no-git-checks --tag "$NPM_TAG"
run: pnpm --filter @immich/sdk publish --provenance --no-git-checks
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -49,7 +49,7 @@ jobs:
working-directory: ./mobile
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+19 -19
View File
@@ -17,7 +17,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -71,7 +71,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -102,7 +102,7 @@ jobs:
working-directory: ./packages/cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -133,7 +133,7 @@ jobs:
working-directory: ./packages/cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -177,7 +177,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -215,7 +215,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -243,7 +243,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -293,7 +293,7 @@ jobs:
working-directory: ./e2e
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -325,7 +325,7 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -361,7 +361,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -374,7 +374,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -438,7 +438,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -451,7 +451,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -546,7 +546,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -583,7 +583,7 @@ jobs:
working-directory: ./machine-learning
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -613,7 +613,7 @@ jobs:
working-directory: ./.github
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -643,7 +643,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -664,7 +664,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -722,7 +722,7 @@ jobs:
- 5432:5432
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -47,7 +47,7 @@ jobs:
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+134
View File
@@ -0,0 +1,134 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation
in our community a harassment-free experience for everyone, regardless
of age, body size, visible or invisible disability, ethnicity, sex
characteristics, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance,
race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open,
welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for
our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our
mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or
political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in
a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our
standards of acceptable behavior and will take appropriate and fair
corrective action in response to any behavior that they deem
inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit,
or reject comments, commits, code, wiki edits, issues, and other
contributions that are not aligned to this Code of Conduct, and will
communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also
applies when an individual is officially representing the community in
public spaces. Examples of representing our community include using an
official e-mail address, posting via an official social media account,
or acting as an appointed representative at an online or offline
event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior
may be reported to the community leaders responsible for enforcement
at our Discord channel. All complaints
will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and
security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in
determining the consequences for any action they deem in violation of
this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior
deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders,
providing clarity around the nature of the violation and an
explanation of why the behavior was inappropriate. A public apology
may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued
behavior. No interaction with the people involved, including
unsolicited interaction with those enforcing the Code of Conduct, for
a specified period of time. This includes avoiding interactions in
community spaces as well as external channels like social
media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards,
including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or
public communication with the community for a specified period of
time. No public or private interaction with the people involved,
including unsolicited interaction with those enforcing the Code of
Conduct, is allowed during this period. Violating these terms may lead
to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of
community standards, including sustained inappropriate behavior,
harassment of an individual, or aggression toward or disparagement of
classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction
within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor
Covenant][homepage], version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of
conduct enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the
FAQ at https://www.contributor-covenant.org/faq. Translations are
available at https://www.contributor-covenant.org/translations.
+5
View File
@@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
Please report security issues to `security@immich.app`
+1 -1
View File
@@ -154,7 +154,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
healthcheck:
test: redis-cli ping || exit 1
+2 -2
View File
@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:69f5241418838263316593f7274a304b095c40bcf22e57272865da91bd60a8ac
image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
+1 -1
View File
@@ -61,7 +61,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
user: '1000:1000'
security_opt:
- no-new-privileges:true
+1 -1
View File
@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
healthcheck:
test: redis-cli ping || exit 1
restart: always
+1 -1
View File
@@ -44,7 +44,7 @@ services:
redis:
container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
healthcheck:
test: redis-cli ping || exit 1
@@ -95,7 +95,6 @@ describe('/server', () => {
major: expect.any(Number),
minor: expect.any(Number),
patch: expect.any(Number),
prerelease: null,
});
});
});
@@ -116,7 +115,6 @@ describe('/server', () => {
oauthAutoLaunch: false,
ocr: false,
passwordLogin: true,
realtimeTranscoding: false,
search: true,
sidecar: true,
trash: true,
@@ -141,7 +139,6 @@ describe('/server', () => {
maintenanceMode: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
minFaces: 3,
});
});
});
@@ -21,18 +21,18 @@ describe('/system-config', () => {
const response1 = await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
.send({ ...config, newVersionCheck: { enabled: false } });
expect(response1.status).toBe(200);
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false } });
const response2 = await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
.send({ ...config, newVersionCheck: { enabled: true } });
expect(response2.status).toBe(200);
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true } });
});
it('should reject an invalid config entry', async () => {
-15
View File
@@ -230,21 +230,6 @@ describe('/users', () => {
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } });
});
it('should update minimum face count to display people', async () => {
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(before).toMatchObject({ people: { minimumFaces: 3 } });
const { status, body } = await request(app)
.put('/users/me/preferences')
.send({ people: { minimumFaces: 2 } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ people: { minimumFaces: 2 } });
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ people: { minimumFaces: 2 } });
});
});
describe('GET /users/:id', () => {
+5 -11
View File
@@ -305,8 +305,6 @@
"refreshing_all_libraries": "Refreshing all libraries",
"registration": "Admin Registration",
"registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.",
"release_channel_release_candidate": "Release candidate",
"release_channel_stable": "Stable",
"remove_failed_jobs": "Remove failed jobs",
"require_password_change_on_login": "Require user to change password on first login",
"reset_settings_to_default": "Reset settings to default",
@@ -401,10 +399,6 @@
"transcoding_preferred_hardware_device_description": "Applies only to VAAPI and QSV. Sets the dri node used for hardware transcoding.",
"transcoding_preset_preset": "Preset (-preset)",
"transcoding_preset_preset_description": "Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above 'faster'.",
"transcoding_realtime": "Real-time Transcoding [EXPERIMENTAL]",
"transcoding_realtime_description": "Allows transcoding to be performed in real-time as the video is being streamed. Enables quality switching, but may cause higher playback latency and stuttering depending on server capabilities.",
"transcoding_realtime_enabled": "Enable real-time transcoding",
"transcoding_realtime_enabled_description": "If disabled, the server will refuse to start new real-time transcoding sessions.",
"transcoding_reference_frames": "Reference frames",
"transcoding_reference_frames_description": "The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically.",
"transcoding_required_description": "Only videos not in an accepted format",
@@ -448,8 +442,6 @@
"user_settings_description": "Manage user settings",
"user_successfully_removed": "User {email} has been successfully removed.",
"users_page_description": "Admin users page",
"version_check_channel": "Release channel",
"version_check_channel_description": "Pick the release channel you want to get version announcements for",
"version_check_enabled_description": "Enable version check",
"version_check_implications": "The version check feature relies on periodic communication with {server}",
"version_check_settings": "Version Check",
@@ -1592,8 +1584,6 @@
"merge_people_prompt": "Do you want to merge these people? This action is irreversible.",
"merge_people_successfully": "Merge people successfully",
"merged_people_count": "Merged {count, plural, one {# person} other {# people}}",
"minFaces": "Minimum faces",
"minFaces_description": "The minimum number of recognized faces for a person to be displayed",
"minimize": "Minimize",
"minute": "Minute",
"minutes": "Minutes",
@@ -2143,6 +2133,11 @@
"share_assets_selected": "{count} selected",
"share_dialog_preparing": "Preparing...",
"share_link": "Share Link",
"share_quality_original": "Original",
"share_quality_original_subtitle": "Send the full-quality original file",
"share_quality_preview": "Preview (JPEG)",
"share_quality_preview_subtitle": "Send a smaller, compressed JPEG",
"share_quality_title": "Share as",
"shared": "Shared",
"shared_album_activities_input_disable": "Comment is disabled",
"shared_album_activity_remove_content": "Do you want to delete this activity?",
@@ -2462,7 +2457,6 @@
"video": "Video",
"video_hover_setting": "Play video thumbnail on hover",
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",
"video_quality": "Video quality",
"videos": "Videos",
"videos_count": "{count, plural, one {# Video} other {# Videos}}",
"videos_only": "Videos only",
+4 -4
View File
@@ -1,8 +1,8 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:121d86b6d08752968a7dddbc708849e5f3a839bbff47f32212b46d2a1d842bab AS builder-cpu
FROM python:3.11-bookworm@sha256:970c99f886b839fc8829289040c1845dadaf2cae46b37acc7710333158ec29b4 AS builder-cpu
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS builder-openvino
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS builder-openvino
FROM builder-cpu AS builder-cuda
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
FROM python:3.11-slim-bookworm@sha256:8dca233de9f3d9bb410665f00a4da6dd06f331083137e0e98ccf227236fcc438 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:9c6f90801e6b68e772b7c0ca74260cbf7af9f320acec894e26fccdaccfbe3b47 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS prod-openvino
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
-1
View File
@@ -49,7 +49,6 @@ try:
str(settings.http_keepalive_timeout_s),
"--graceful-timeout",
"10",
"--no-control-socket",
],
) as cmd:
cmd.wait()
+1 -2
View File
@@ -12,7 +12,7 @@ from zipfile import BadZipFile
import orjson
from fastapi import Depends, FastAPI, File, Form, HTTPException
from fastapi.responses import PlainTextResponse
from fastapi.responses import ORJSONResponse, PlainTextResponse
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
from PIL.Image import Image
from pydantic import ValidationError
@@ -32,7 +32,6 @@ from .schemas import (
ModelIdentity,
ModelTask,
ModelType,
ORJSONResponse,
PipelineRequest,
T,
)
@@ -89,9 +89,7 @@ class OpenClipTextualEncoder(BaseCLIPTextualEncoder):
tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix())
pad_id = tokenizer.token_to_id(pad_token)
if pad_id is None:
raise ValueError(f"Pad token '{pad_token}' not found in tokenizer vocab")
pad_id: int = tokenizer.token_to_id(pad_token)
tokenizer.enable_padding(length=context_length, pad_token=pad_token, pad_id=pad_id)
tokenizer.enable_truncation(max_length=context_length)
-7
View File
@@ -3,16 +3,9 @@ from typing import Any, Literal, Protocol, TypeGuard, TypeVar
import numpy as np
import numpy.typing as npt
import orjson
from fastapi.responses import JSONResponse
from typing_extensions import TypedDict
class ORJSONResponse(JSONResponse):
def render(self, content: Any) -> bytes:
return orjson.dumps(content, option=orjson.OPT_SERIALIZE_NUMPY)
class StrEnum(str, Enum):
value: str
+450 -498
View File
File diff suppressed because it is too large Load Diff
+79 -93
View File
@@ -9,7 +9,7 @@
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
467DA6EAF83F3481F8BD94AB /* Pods_ShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8AB817AA297EDEC88B23F3F6 /* Pods_ShareExtension.framework */; };
3B6A31FED0FC846D6BD69BBC /* Pods_ShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -22,7 +22,7 @@
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */; };
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */; };
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
D3BED739C0BC29BB32E18EB2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC499FBCE6B29B2DAFED7130 /* Pods_Runner.framework */; };
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
F0B57D3A2DF764BD00DC5BCC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */; };
F0B57D3C2DF764BD00DC5BCC /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D3B2DF764BD00DC5BCC /* SwiftUI.framework */; };
@@ -85,18 +85,16 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
10B378D23F917891A0F23E33 /* Pods-ShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.release.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.release.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
2E3441B73560D0F6FD25E04F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
614A7F5DC5DB09E89E4FCBE8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
681FBA560D5D2ADDE4F0B59E /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
6D160F04A389B9FFBC557803 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
571EAA93D77181C7C98C2EA6 /* Pods-ShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.release.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.release.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
8AB817AA297EDEC88B23F3F6 /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
937632897A02DE9C249F20A6 /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Immich-Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Immich-Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -105,6 +103,7 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = "<group>"; };
B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = "<group>"; };
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
@@ -112,11 +111,12 @@
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApi.g.swift; sourceTree = "<group>"; };
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApiImpl.swift; sourceTree = "<group>"; };
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
C4A6A71F33CE37B3C913115C /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
CC499FBCE6B29B2DAFED7130 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
F0B57D3B2DF764BD00DC5BCC /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
F7101BB0391A314774615E89 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
FA9973382CF6DF4B000EF859 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
FAC6F8902D287C890078CB2F /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
FAC6F8B12D287F120078CB2F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@@ -199,7 +199,7 @@
FEE084F82EC172460045228E /* SQLiteData in Frameworks */,
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */,
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */,
D3BED739C0BC29BB32E18EB2 /* Pods_Runner.framework in Frameworks */,
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -216,7 +216,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
467DA6EAF83F3481F8BD94AB /* Pods_ShareExtension.framework in Frameworks */,
3B6A31FED0FC846D6BD69BBC /* Pods_ShareExtension.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -226,12 +226,12 @@
0FB772A5B9601143383626CA /* Pods */ = {
isa = PBXGroup;
children = (
614A7F5DC5DB09E89E4FCBE8 /* Pods-Runner.debug.xcconfig */,
6D160F04A389B9FFBC557803 /* Pods-Runner.release.xcconfig */,
681FBA560D5D2ADDE4F0B59E /* Pods-Runner.profile.xcconfig */,
937632897A02DE9C249F20A6 /* Pods-ShareExtension.debug.xcconfig */,
10B378D23F917891A0F23E33 /* Pods-ShareExtension.release.xcconfig */,
C4A6A71F33CE37B3C913115C /* Pods-ShareExtension.profile.xcconfig */,
2E3441B73560D0F6FD25E04F /* Pods-Runner.debug.xcconfig */,
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */,
F7101BB0391A314774615E89 /* Pods-Runner.profile.xcconfig */,
F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */,
571EAA93D77181C7C98C2EA6 /* Pods-ShareExtension.release.xcconfig */,
B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -239,10 +239,10 @@
1754452DD81DA6620E279E51 /* Frameworks */ = {
isa = PBXGroup;
children = (
886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */,
357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */,
F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */,
F0B57D3B2DF764BD00DC5BCC /* SwiftUI.framework */,
CC499FBCE6B29B2DAFED7130 /* Pods_Runner.framework */,
8AB817AA297EDEC88B23F3F6 /* Pods_ShareExtension.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -370,7 +370,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
BAEA01ACA3F5C9CD3D732370 /* [CP] Check Pods Manifest.lock */,
4044AF030EF7D8721844FFBA /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
@@ -378,8 +378,8 @@
9705A1C41CF9048500538489 /* Embed Frameworks */,
FAC6F89A2D287C890078CB2F /* Embed Foundation Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
513DA7292DED6106813332F4 /* [CP] Embed Pods Frameworks */,
2FA39DEC809D6D7C4A01EFCB /* [CP] Copy Pods Resources */,
D218A34AEE62BC1EF119F5B0 /* [CP] Embed Pods Frameworks */,
6724EEB7D74949FA08581154 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -393,9 +393,6 @@
FEE084F22EC172080045228E /* Schemas */,
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Immich-Debug.app */;
productType = "com.apple.product-type.application";
@@ -424,7 +421,7 @@
isa = PBXNativeTarget;
buildConfigurationList = FAC6F8A02D287C890078CB2F /* Build configuration list for PBXNativeTarget "ShareExtension" */;
buildPhases = (
8EC9CF3E20AF32BF24D4F3E1 /* [CP] Check Pods Manifest.lock */,
3BEF3D71D97E337D921C0EB5 /* [CP] Check Pods Manifest.lock */,
FAC6F88C2D287C890078CB2F /* Sources */,
FAC6F88D2D287C890078CB2F /* Frameworks */,
FAC6F88E2D287C890078CB2F /* Resources */,
@@ -473,7 +470,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
);
@@ -520,23 +517,6 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
2FA39DEC809D6D7C4A01EFCB /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -553,24 +533,7 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
513DA7292DED6106813332F4 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
8EC9CF3E20AF32BF24D4F3E1 /* [CP] Check Pods Manifest.lock */ = {
3BEF3D71D97E337D921C0EB5 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -592,22 +555,7 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
};
BAEA01ACA3F5C9CD3D732370 /* [CP] Check Pods Manifest.lock */ = {
4044AF030EF7D8721844FFBA /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -629,6 +577,55 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
6724EEB7D74949FA08581154 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
};
D218A34AEE62BC1EF119F5B0 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -1095,7 +1092,7 @@
};
FAC6F89C2D287C890078CB2F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 937632897A02DE9C249F20A6 /* Pods-ShareExtension.debug.xcconfig */;
baseConfigurationReference = F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -1138,7 +1135,7 @@
};
FAC6F89D2D287C890078CB2F /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 10B378D23F917891A0F23E33 /* Pods-ShareExtension.release.xcconfig */;
baseConfigurationReference = 571EAA93D77181C7C98C2EA6 /* Pods-ShareExtension.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -1178,7 +1175,7 @@
};
FAC6F89E2D287C890078CB2F /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = C4A6A71F33CE37B3C913115C /* Pods-ShareExtension.profile.xcconfig */;
baseConfigurationReference = B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -1261,13 +1258,6 @@
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */ = {
isa = XCRemoteSwiftPackageReference;
@@ -1288,10 +1278,6 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
FEE084F72EC172460045228E /* SQLiteData */ = {
isa = XCSwiftPackageProductDependency;
package = FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */;
@@ -5,8 +5,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : {
"revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6",
"version" : "1.1.0"
"revision" : "5928286acce13def418ec36d05a001a9641086f2",
"version" : "1.0.3"
}
},
{
+1 -1
View File
@@ -49,7 +49,7 @@ def get_version_from_pubspec
pubspec = YAML.load_file(pubspec_path)
version_string = pubspec['version']
version_string ? version_string.split('+').first.split('-').first : nil
version_string ? version_string.split('+').first : nil
end
# Helper method to configure code signing for all targets
-2
View File
@@ -22,5 +22,3 @@ enum AssetDateAggregation { start, end }
enum SlideshowLook { contain, cover, blurredBackground }
enum SlideshowDirection { forward, backward, shuffle }
enum PartnerDirection { sharedBy, sharedWith }
-112
View File
@@ -237,115 +237,3 @@ class PartnerUserDto {
return id.hashCode ^ email.hashCode ^ name.hashCode ^ inTimeline.hashCode ^ profileImagePath.hashCode;
}
}
class User {
final String id;
final String name;
final String email;
final DateTime profileChangedAt;
final bool hasProfileImage;
final AvatarColor? avatarColor;
const User({
required this.id,
required this.name,
required this.email,
required this.profileChangedAt,
required this.hasProfileImage,
this.avatarColor = AvatarColor.primary,
});
@override
String toString() {
return 'User(id: $id, name: $name, email: $email, profileChangedAt: $profileChangedAt, hasProfileImage: $hasProfileImage, avatarColor: $avatarColor)';
}
@override
bool operator ==(covariant User other) {
if (identical(this, other)) {
return true;
}
return other.id == id &&
other.name == name &&
other.email == email &&
other.profileChangedAt == profileChangedAt &&
other.hasProfileImage == hasProfileImage &&
other.avatarColor == avatarColor;
}
@override
int get hashCode => Object.hash(id, name, email, profileChangedAt, hasProfileImage, avatarColor);
}
class AuthUser extends User {
final bool isAdmin;
final String? pinCode;
final int? quotaSizeInBytes;
final int quotaUsageInBytes;
const AuthUser({
required super.id,
required super.name,
required super.email,
required super.profileChangedAt,
required super.hasProfileImage,
super.avatarColor,
this.isAdmin = false,
this.pinCode,
this.quotaSizeInBytes = 0,
this.quotaUsageInBytes = 0,
});
@override
String toString() {
return 'AuthUser(user: ${super.toString()}, isAdmin: $isAdmin, pinCode: $pinCode, quotaSizeInBytes: $quotaSizeInBytes, quotaUsageInBytes: $quotaUsageInBytes)';
}
@override
bool operator ==(covariant AuthUser other) {
if (identical(this, other)) {
return true;
}
return super == other &&
other.isAdmin == isAdmin &&
other.pinCode == pinCode &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes;
}
@override
int get hashCode => Object.hash(super.hashCode, isAdmin, pinCode, quotaSizeInBytes, quotaUsageInBytes);
}
class Partner extends User {
final bool inTimeline;
const Partner({
required super.id,
required super.name,
required super.email,
required super.profileChangedAt,
required super.hasProfileImage,
super.avatarColor,
this.inTimeline = false,
});
@override
String toString() {
return 'Partner(user: ${super.toString()}, inTimeline: $inTimeline)';
}
@override
bool operator ==(covariant Partner other) {
if (identical(this, other)) {
return true;
}
return super == other && other.inTimeline == inTimeline;
}
@override
int get hashCode => Object.hash(super.hashCode, inTimeline);
}
+34 -25
View File
@@ -1,42 +1,51 @@
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:stream_transform/stream_transform.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class PartnerService {
final UserRepository _userRepository;
final PartnerRepository _partnerRepository;
class DriftPartnerService {
final DriftPartnerRepository _driftPartnerRepository;
final PartnerApiRepository _partnerApiRepository;
const PartnerService(this._userRepository, this._partnerRepository, this._partnerApiRepository);
const DriftPartnerService(this._driftPartnerRepository, this._partnerApiRepository);
Stream<Iterable<User>> getCandidates(String userId) {
final userStream = _userRepository.getAll();
final partnerStream = _partnerRepository.search(userId, .sharedBy);
return userStream.combineLatest(partnerStream, (users, partners) {
final partnersSet = partners.map((partner) => partner.id).toSet();
return users.where((user) => user.id != userId && !partnersSet.contains(user.id));
});
Future<List<PartnerUserDto>> getSharedWith(String userId) {
return _driftPartnerRepository.getSharedWith(userId);
}
Stream<Iterable<Partner>> search(String userId, PartnerDirection direction) =>
_partnerRepository.search(userId, direction);
Future<void> update(String partnerId, String userId, {required bool inTimeline}) async {
await _partnerApiRepository.update(partnerId, inTimeline: inTimeline);
await _partnerRepository.update(partnerId, userId, inTimeline: inTimeline);
Future<List<PartnerUserDto>> getSharedBy(String userId) {
return _driftPartnerRepository.getSharedBy(userId);
}
Future<void> create(String partnerId, String userId) async {
Future<List<PartnerUserDto>> getAvailablePartners(String currentUserId) async {
final otherUsers = await _driftPartnerRepository.getAvailablePartners(currentUserId);
final currentPartners = await _driftPartnerRepository.getSharedBy(currentUserId);
final available = otherUsers.where((user) {
return !currentPartners.any((partner) => partner.id == user.id);
}).toList();
return available;
}
Future<void> toggleShowInTimeline(String partnerId, String userId) async {
final partner = await _driftPartnerRepository.getPartner(partnerId, userId);
if (partner == null) {
dPrint(() => "Partner not found: $partnerId for user: $userId");
return;
}
await _partnerApiRepository.update(partnerId, inTimeline: !partner.inTimeline);
await _driftPartnerRepository.toggleShowInTimeline(partner, userId);
}
Future<void> addPartner(String partnerId, String userId) async {
await _partnerApiRepository.create(partnerId);
await _partnerRepository.create(partnerId, userId);
await _driftPartnerRepository.create(partnerId, userId);
}
Future<void> delete(String partnerId, String userId) async {
Future<void> removePartner(String partnerId, String userId) async {
await _partnerApiRepository.delete(partnerId);
await _partnerRepository.delete(partnerId, userId);
await _driftPartnerRepository.delete(partnerId, userId);
}
}
@@ -192,30 +192,43 @@ class RemoteAlbumService {
required UserDto uploader,
required AlbumAssetCandidates candidates,
UploadCallbacks uploadCallbacks = const UploadCallbacks(),
Completer<void>? cancelToken,
}) async {
int addedCount = 0;
if (candidates.remoteAssetIds.isNotEmpty) {
addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds);
}
if (candidates.localAssetsToUpload.isNotEmpty) {
addedCount += await _uploadAndAddLocals(
albumId,
uploader,
candidates.localAssetsToUpload,
uploadCallbacks,
cancelToken,
);
addedCount += await _uploadAndAddLocals(albumId, uploader, candidates.localAssetsToUpload, uploadCallbacks);
}
return addedCount;
}
/// Creates an album, seeding it with already-remote asset IDs, then uploads
/// local-only assets and links each one as it finishes.
Future<RemoteAlbum> createAlbumWithAssets({
required String title,
required UserDto owner,
String? description,
AlbumAssetCandidates candidates = const AlbumAssetCandidates(remoteAssetIds: [], localAssetsToUpload: []),
UploadCallbacks uploadCallbacks = const UploadCallbacks(),
}) async {
final album = await createAlbum(
title: title,
owner: owner,
description: description,
assetIds: candidates.remoteAssetIds,
);
if (candidates.localAssetsToUpload.isNotEmpty) {
await _uploadAndAddLocals(album.id, owner, candidates.localAssetsToUpload, uploadCallbacks);
}
return album;
}
Future<int> _uploadAndAddLocals(
String albumId,
UserDto uploader,
List<LocalAsset> localAssets,
UploadCallbacks userCallbacks,
Completer<void>? cancelToken,
) async {
int addedCount = 0;
final pendingAdds = <Future<void>>[];
@@ -245,7 +258,7 @@ class RemoteAlbumService {
return;
}
pendingAdds.add(
linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
.then<void>((added) {
addedCount += added;
})
@@ -256,7 +269,7 @@ class RemoteAlbumService {
},
);
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks, cancelToken: cancelToken);
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks);
await Future.wait(pendingAdds);
return addedCount;
}
@@ -275,7 +288,7 @@ class RemoteAlbumService {
/// `remote_asset_entity` row from the local source so the FK-protected
/// junction insert succeeds. Sync overwrites the placeholder later with
/// the authoritative server data.
Future<int> linkUploadedAssetToAlbum(String albumId, String remoteId, UserDto uploader, LocalAsset source) async {
Future<int> _linkUploadedAssetToAlbum(String albumId, String remoteId, UserDto uploader, LocalAsset source) async {
final result = await _albumApiRepository.addAssets(albumId, [remoteId]);
if (result.added.isEmpty) {
return 0;
+138
View File
@@ -0,0 +1,138 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
/// The quality at which an asset is shared.
enum ShareAssetQuality {
/// Share the full original file. Read from the device when available,
/// otherwise downloaded from the server.
original,
/// Share the server-generated JPEG preview. Smaller and faster to send, and
/// never exposes the original (e.g. RAW) file.
preview,
}
/// Where the bytes of a shared asset are obtained from.
enum ShareSourceKind {
/// Read the original file directly from the device.
localFile,
/// Download the full original file from the server.
remoteOriginal,
/// Download the server-generated JPEG preview from the server.
remotePreview,
}
/// Resolved instruction describing how a single asset should be shared.
///
/// This is a plain value object produced by [resolveShareSource] so that the
/// decision of *what* to share can be unit-tested independently from the side
/// effects of actually reading/downloading and handing the file to the OS share
/// sheet.
class ShareSource {
final ShareSourceKind kind;
/// Device asset id, set when [kind] is [ShareSourceKind.localFile].
final String? localId;
/// Server asset id, set when [kind] requires a download.
final String? remoteId;
const ShareSource._({required this.kind, this.localId, this.remoteId});
const ShareSource.localFile(String id) : this._(kind: ShareSourceKind.localFile, localId: id);
const ShareSource.remoteOriginal(String id) : this._(kind: ShareSourceKind.remoteOriginal, remoteId: id);
const ShareSource.remotePreview(String id) : this._(kind: ShareSourceKind.remotePreview, remoteId: id);
/// Whether the file is read from the device instead of downloaded.
bool get isLocal => kind == ShareSourceKind.localFile;
/// Whether the JPEG preview (rather than the original) is shared.
bool get isPreview => kind == ShareSourceKind.remotePreview;
/// Whether the file has to be fetched from the server.
bool get requiresDownload => kind != ShareSourceKind.localFile;
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ShareSource && other.kind == kind && other.localId == localId && other.remoteId == remoteId);
@override
int get hashCode => Object.hash(kind, localId, remoteId);
@override
String toString() => 'ShareSource(kind: $kind, localId: $localId, remoteId: $remoteId)';
}
/// Whether [asset] can be shared as a JPEG preview.
///
/// The preview is generated and stored by the server, so it only exists for
/// assets that have a remote copy. It is a still JPEG, so it only makes sense
/// for images - sharing a still frame of a video would be surprising.
bool canShareAsPreview(BaseAsset asset) => asset.isImage && asset.remoteId != null;
/// Whether offering a quality choice is meaningful for [assets].
///
/// The choice only matters when at least one asset can actually provide a JPEG
/// preview; otherwise sharing always falls back to the original and the picker
/// would be a no-op.
bool shouldOfferShareQualityChoice(Iterable<BaseAsset> assets) => assets.any(canShareAsPreview);
/// Resolves how [asset] should be shared at the requested [quality].
///
/// Handles all three asset states - local-only, remote-only and merged - and
/// degrades gracefully when the requested quality is not available:
///
/// * [ShareAssetQuality.preview] needs a remote image. For videos or
/// local-only assets it is not available, so the original is shared instead.
/// * [ShareAssetQuality.original] prefers the on-device file, but server-side
/// edits only exist remotely, so an edited asset must be downloaded.
///
/// Returns `null` when the asset can neither be read locally nor downloaded.
ShareSource? resolveShareSource(BaseAsset asset, ShareAssetQuality quality) {
final localId = asset.localId;
final remoteId = asset.remoteId;
if (quality == ShareAssetQuality.preview && canShareAsPreview(asset)) {
// canShareAsPreview guarantees a non-null remoteId.
return ShareSource.remotePreview(remoteId!);
}
// Original quality.
// The on-device file is the true original, but an edited asset only carries
// its edits on the server, so prefer the remote copy in that case.
if (localId != null && !asset.isEdited) {
return ShareSource.localFile(localId);
}
if (remoteId != null) {
return ShareSource.remoteOriginal(remoteId);
}
// Local-only asset flagged as edited: there is no remote to download from, so
// fall back to the local file.
if (localId != null) {
return ShareSource.localFile(localId);
}
return null;
}
/// Builds the filename to use for the shared file.
///
/// Path separators are stripped to keep the name safe to write to a temporary
/// directory. Previews are always JPEG, so the extension is normalized to
/// `.jpg` (the original might be e.g. a `.CR2`/`.dng` RAW file).
String shareFilename(BaseAsset asset, ShareSource source) {
final sanitized = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
if (!source.isPreview) {
return sanitized;
}
final dotIndex = sanitized.lastIndexOf('.');
final base = dotIndex > 0 ? sanitized.substring(0, dotIndex) : sanitized;
return '$base.jpg';
}
@@ -1,8 +1,5 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)')
@@ -17,14 +14,4 @@ class PartnerEntity extends Table with DriftDefaultsMixin {
@override
Set<Column> get primaryKey => {sharedById, sharedWithId};
static Partner rowToPartner(UserEntityData user, PartnerEntityData partner) => Partner(
id: user.id,
email: user.email,
name: user.name,
profileChangedAt: user.profileChangedAt,
hasProfileImage: user.hasProfileImage,
avatarColor: user.avatarColor,
inTimeline: partner.inTimeline,
);
}
@@ -1,6 +1,5 @@
import 'package:drift/drift.dart' hide Index;
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class UserEntity extends Table with DriftDefaultsMixin {
@@ -17,13 +16,4 @@ class UserEntity extends Table with DriftDefaultsMixin {
@override
Set<Column> get primaryKey => {id};
static User rowToUser(UserEntityData row) => User(
id: row.id,
name: row.name,
email: row.email,
profileChangedAt: row.profileChangedAt,
hasProfileImage: row.hasProfileImage,
avatarColor: row.avatarColor,
);
}
@@ -1,55 +1,106 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.dart';
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class PartnerRepository {
class DriftPartnerRepository extends DriftDatabaseRepository {
final Drift _db;
const PartnerRepository(this._db);
const DriftPartnerRepository(this._db) : super(_db);
Partner _resultToPartner(TypedResult result) {
final user = result.readTable(_db.userEntity);
final partner = result.readTable(_db.partnerEntity);
return PartnerEntity.rowToPartner(user, partner);
Future<List<PartnerUserDto>> getPartners(String userId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
])..where(_db.partnerEntity.sharedWithId.equals(userId));
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
}).get();
}
Future<Partner> get(String partnerId, String userId) =>
(_db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
])..where(_db.partnerEntity.sharedById.equals(partnerId) & _db.partnerEntity.sharedWithId.equals(userId)))
.map(_resultToPartner)
.getSingle();
// Get users who we can share our library with
Future<List<PartnerUserDto>> getAvailablePartners(String currentUserId) {
final query = _db.select(_db.userEntity)..where((row) => row.id.equals(currentUserId).not());
Stream<Iterable<Partner>> search(String userId, PartnerDirection direction) =>
(_db.select(_db.partnerEntity).join([
innerJoin(
_db.userEntity,
_db.userEntity.id.equalsExp(switch (direction) {
.sharedBy => _db.partnerEntity.sharedWithId,
.sharedWith => _db.partnerEntity.sharedById,
}),
),
])..where(
switch (direction) {
.sharedBy => _db.partnerEntity.sharedById,
.sharedWith => _db.partnerEntity.sharedWithId,
}.equals(userId) &
_db.userEntity.id.equals(userId).not(),
))
.map(_resultToPartner)
.watch();
return query.map((user) {
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: false);
}).get();
}
Future<void> create(String partnerId, String userId) => _db.partnerEntity.insertOnConflictUpdate(
PartnerEntityCompanion(sharedById: Value(userId), sharedWithId: Value(partnerId), inTimeline: const Value(false)),
);
// Get users who are sharing their photos WITH the current user
Future<List<PartnerUserDto>> getSharedWith(String partnerId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
])..where(_db.partnerEntity.sharedWithId.equals(partnerId));
Future<void> update(String partnerId, String userId, {required bool inTimeline}) =>
(_db.partnerEntity.update()..where((t) => t.sharedById.equals(partnerId) & t.sharedWithId.equals(userId))).write(
PartnerEntityCompanion(inTimeline: Value(inTimeline)),
);
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
}).get();
}
Future<void> delete(String partnerId, String userId) =>
(_db.partnerEntity.delete()..where((t) => t.sharedById.equals(userId) & t.sharedWithId.equals(partnerId))).go();
// Get users who the current user is sharing their photos TO
Future<List<PartnerUserDto>> getSharedBy(String userId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedWithId)),
])..where(_db.partnerEntity.sharedById.equals(userId));
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
}).get();
}
Future<List<String>> getAllPartnerIds(String userId) async {
// Get users who are sharing with me (sharedWithId = userId)
final sharingWithMeQuery = _db.select(_db.partnerEntity)..where((tbl) => tbl.sharedWithId.equals(userId));
final sharingWithMe = await sharingWithMeQuery.map((row) => row.sharedById).get();
// Get users who I am sharing with (sharedById = userId)
final sharingWithThemQuery = _db.select(_db.partnerEntity)..where((tbl) => tbl.sharedById.equals(userId));
final sharingWithThem = await sharingWithThemQuery.map((row) => row.sharedWithId).get();
// Combine both lists and remove duplicates
final allPartnerIds = <String>{...sharingWithMe, ...sharingWithThem}.toList();
return allPartnerIds;
}
Future<PartnerUserDto?> getPartner(String partnerId, String userId) {
final query = _db.select(_db.partnerEntity).join([
innerJoin(_db.userEntity, _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById)),
])..where(_db.partnerEntity.sharedById.equals(partnerId) & _db.partnerEntity.sharedWithId.equals(userId));
return query.map((row) {
final user = row.readTable(_db.userEntity);
final partner = row.readTable(_db.partnerEntity);
return PartnerUserDto(id: user.id, email: user.email, name: user.name, inTimeline: partner.inTimeline);
}).getSingleOrNull();
}
Future<bool> toggleShowInTimeline(PartnerUserDto partner, String userId) {
return _db.partnerEntity.update().replace(
PartnerEntityCompanion(
sharedById: Value(partner.id),
sharedWithId: Value(userId),
inTimeline: Value(!partner.inTimeline),
),
);
}
Future<int> create(String partnerId, String userId) {
final entity = PartnerEntityCompanion(
sharedById: Value(userId),
sharedWithId: Value(partnerId),
inTimeline: const Value(false),
);
return _db.partnerEntity.insertOne(entity);
}
Future<void> delete(String partnerId, String userId) {
return _db.partnerEntity.deleteWhere((t) => t.sharedById.equals(userId) & t.sharedWithId.equals(partnerId));
}
}
@@ -2,17 +2,9 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
class UserRepository {
final Drift _db;
const UserRepository(this._db);
Stream<Iterable<User>> getAll() => _db.select(_db.userEntity).map(UserEntity.rowToUser).watch();
}
class DriftAuthUserRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftAuthUserRepository(super.db) : _db = db;
@@ -2,12 +2,16 @@ import 'package:immich_mobile/utils/semver.dart';
import 'package:openapi/api.dart';
class ServerVersion extends SemVer {
const ServerVersion({required super.major, required super.minor, required super.patch, super.prerelease});
const ServerVersion({required super.major, required super.minor, required super.patch});
ServerVersion.fromDto(ServerVersionResponseDto dto)
: super(major: dto.major, minor: dto.minor, patch: dto.patch_, prerelease: dto.prerelease);
@override
String toString() {
return 'ServerVersion(major: $major, minor: $minor, patch: $patch)';
}
bool isAtLeast({int major = 0, int minor = 0, int patch = 0, int? prerelease}) {
return this >= SemVer(major: major, minor: minor, patch: patch, prerelease: prerelease);
ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_);
bool isAtLeast({int major = 0, int minor = 0, int patch = 0}) {
return this >= SemVer(major: major, minor: minor, patch: patch);
}
}
@@ -3,192 +3,137 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
@visibleForTesting
final candidatesProvider = StreamProvider.autoDispose<Iterable<User>>((ref) {
final currentUser = ref.watch(currentUserProvider);
// TODO: Refactor with a route guard to avoid this check in every provider
if (currentUser == null) {
return const Stream.empty();
}
return ref.watch(partnerServiceProvider).getCandidates(currentUser.id);
});
@visibleForTesting
final partnersProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
final currentUser = ref.watch(currentUserProvider);
// TODO: Refactor with a route guard to avoid this check in every provider
if (currentUser == null) {
return const Stream.empty();
}
return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedBy);
});
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class DriftPartnerPage extends ConsumerWidget {
class DriftPartnerPage extends HookConsumerWidget {
const DriftPartnerPage({super.key});
Future<void> _addPartner(BuildContext context, WidgetRef ref) async {
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
final currentUser = ref.read(currentUserProvider);
if (selected != null && currentUser != null) {
await ref.read(partnerServiceProvider).create(selected.id, currentUser.id);
}
}
Future<void> _removePartner(BuildContext context, WidgetRef ref, Partner partner) => showDialog(
context: context,
builder: (_) => ConfirmDialog(
title: "stop_photo_sharing",
content: context.t.partner_page_stop_sharing_content(partner: partner.name),
onOk: () {
final currentUser = ref.read(currentUserProvider);
if (currentUser != null) {
ref.read(partnerServiceProvider).delete(partner.id, currentUser.id);
}
},
),
);
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedByAsync = ref.watch(partnersProvider);
final potentialPartnersAsync = ref.watch(driftAvailablePartnerProvider);
addNewUsersHandler() async {
final potentialPartners = potentialPartnersAsync.value;
if (potentialPartners == null || potentialPartners.isEmpty) {
ImmichToast.show(context: context, msg: "partner_page_no_more_users".tr());
return;
}
final selectedUser = await showDialog<PartnerUserDto>(
context: context,
builder: (context) {
return SimpleDialog(
title: const Text("partner_page_select_partner").tr(),
children: [
for (PartnerUserDto partner in potentialPartners)
SimpleDialogOption(
onPressed: () => context.pop(partner),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: PartnerUserAvatar(partner: partner),
),
Text(partner.name),
],
),
),
],
);
},
);
if (selectedUser != null) {
await ref.read(partnerUsersProvider.notifier).addPartner(selectedUser);
}
}
onDeleteUser(PartnerUserDto partner) {
return showDialog(
context: context,
builder: (BuildContext context) {
return ConfirmDialog(
title: "stop_photo_sharing",
content: "partner_page_stop_sharing_content".tr(namedArgs: {'partner': partner.name}),
onOk: () => ref.read(partnerUsersProvider.notifier).removePartner(partner),
);
},
);
}
return Scaffold(
appBar: AppBar(
title: Text(context.t.partners),
title: const Text("partners").t(context: context),
elevation: 0,
centerTitle: false,
actions: [
IconButton(
onPressed: () => _addPartner(context, ref),
onPressed: potentialPartnersAsync.whenOrNull(data: (data) => addNewUsersHandler),
icon: const Icon(Icons.person_add),
tooltip: context.t.add_partner,
tooltip: "add_partner".tr(),
),
],
),
body: sharedByAsync.when(
data: (partners) => PartnerSharedByList(
partners: partners.toList(growable: false),
onAddPartner: () => _addPartner(context, ref),
onRemovePartner: (partner) => _removePartner(context, ref, partner),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text(context.t.error_loading_partners(error: error))),
),
body: _SharedToPartnerList(onAddPartner: addNewUsersHandler, onDeletePartner: onDeleteUser),
);
}
}
@visibleForTesting
class PartnerSharedByList extends StatelessWidget {
const PartnerSharedByList({
super.key,
required this.partners,
required this.onAddPartner,
required this.onRemovePartner,
});
final List<Partner> partners;
class _SharedToPartnerList extends ConsumerWidget {
final VoidCallback onAddPartner;
final ValueChanged<Partner> onRemovePartner;
final Function(PartnerUserDto partner) onDeletePartner;
@override
Widget build(BuildContext context) {
if (partners.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)),
),
Align(
alignment: Alignment.center,
child: ElevatedButton.icon(
onPressed: onAddPartner,
icon: const Icon(Icons.person_add),
label: Text(context.t.add_partner),
),
),
],
),
);
}
return ListView.builder(
itemCount: partners.length,
itemBuilder: (_, index) {
final partner = partners[index];
return ListTile(
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
title: Text(partner.name),
subtitle: Text(partner.email),
trailing: IconButton(icon: const Icon(Icons.person_remove), onPressed: () => onRemovePartner(partner)),
);
},
);
}
}
@visibleForTesting
class PartnerSelectionDialog extends ConsumerWidget {
const PartnerSelectionDialog({super.key});
const _SharedToPartnerList({required this.onAddPartner, required this.onDeletePartner});
@override
Widget build(BuildContext context, WidgetRef ref) {
final candidatesAsync = ref.watch(candidatesProvider);
final partnerAsync = ref.watch(driftSharedByPartnerProvider);
return SimpleDialog(
title: const Text("partner_page_select_partner").tr(),
children: candidatesAsync.when(
data: (candidates) {
final users = candidates.toList();
if (users.isEmpty) {
return [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: const Text("partner_page_no_more_users").tr(),
),
];
}
return [
for (final candidate in users)
SimpleDialogOption(
onPressed: () => Navigator.of(context).pop(candidate),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: PartnerUserAvatar(userId: candidate.id, name: candidate.name),
),
Text(candidate.name),
],
return partnerAsync.when(
data: (partners) {
if (partners.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: const Text("partner_page_empty_message", style: TextStyle(fontSize: 14)).tr(),
),
),
];
},
loading: () => const [
Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
),
],
error: (error, _) => [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Text("error_loading_partners".tr(args: [error.toString()])),
),
],
),
Align(
alignment: Alignment.center,
child: ElevatedButton.icon(
onPressed: onAddPartner,
icon: const Icon(Icons.person_add),
label: const Text("add_partner").tr(),
),
),
],
),
);
}
return ListView.builder(
itemCount: partners.length,
itemBuilder: (context, index) {
final partner = partners[index];
return ListTile(
leading: PartnerUserAvatar(partner: partner),
title: Text(partner.name),
subtitle: Text(partner.email),
trailing: IconButton(icon: const Icon(Icons.person_remove), onPressed: () => onDeletePartner(partner)),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('error_loading_partners'.tr(args: [error.toString()]))),
);
}
}
@@ -7,13 +7,12 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
@@ -328,17 +327,6 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
}
}
@visibleForTesting
final driftSharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
final currentUser = ref.watch(currentUserProvider);
if (currentUser == null) {
// TODO: Refactor with a route guard to avoid this check in every provider
return const Stream.empty();
}
return ref.watch(partnerServiceProvider).search(currentUser.id, .sharedWith);
});
class _QuickAccessButtonList extends ConsumerWidget {
const _QuickAccessButtonList();
@@ -401,7 +389,7 @@ class _QuickAccessButtonList extends ConsumerWidget {
),
onTap: () => context.pushRoute(const DriftPartnerRoute()),
),
_PartnerList(partners: partners.toList()),
_PartnerList(partners: partners),
],
),
),
@@ -413,7 +401,7 @@ class _QuickAccessButtonList extends ConsumerWidget {
class _PartnerList extends StatelessWidget {
const _PartnerList({required this.partners});
final List<Partner> partners;
final List<PartnerUserDto> partners;
@override
Widget build(BuildContext context) {
@@ -433,7 +421,7 @@ class _PartnerList extends StatelessWidget {
),
),
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
leading: PartnerUserAvatar(userId: partner.id, name: partner.name),
leading: PartnerUserAvatar(partner: partner),
title: const Text(
"partner_list_user_photos",
style: TextStyle(fontWeight: FontWeight.w500),
@@ -8,13 +8,13 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/utils/debug_print.dart';
@RoutePage()
class DriftPartnerDetailPage extends StatelessWidget {
final Partner partner;
final PartnerUserDto partner;
const DriftPartnerDetailPage({super.key, required this.partner});
@@ -39,7 +39,7 @@ class DriftPartnerDetailPage extends StatelessWidget {
}
class _InfoBox extends ConsumerStatefulWidget {
final Partner partner;
final PartnerUserDto partner;
const _InfoBox({required this.partner});
@@ -63,7 +63,7 @@ class _InfoBoxState extends ConsumerState<_InfoBox> {
}
try {
await ref.read(partnerServiceProvider).update(widget.partner.id, user.id, inTimeline: !_inTimeline);
await ref.read(partnerUsersProvider.notifier).toggleShowInTimeline(widget.partner.id, user.id);
setState(() {
_inTimeline = !_inTimeline;
@@ -6,7 +6,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -143,18 +142,13 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
return;
}
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.viewer, album);
final addedCount = await ref.read(remoteAlbumProvider.notifier).addAssets(album.id, [latest.remoteId!]);
if (!context.mounted) {
return;
}
if (!result.success) {
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
return;
}
if (result.count == 0) {
if (addedCount == 0) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}),
@@ -165,7 +159,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
// Refresh the "Appears in" list on the asset's info panel.
// Invalidate using the asset's remote ID to refresh the "Appears in" list
ref.invalidate(albumsContainingAssetProvider(latest.remoteId!));
}
@@ -6,9 +6,11 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/utils/share_asset.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_quality_picker.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -60,6 +62,19 @@ class ShareActionButton extends ConsumerWidget {
return;
}
// Let the user pick the quality, but only when at least one asset can
// actually be shared as a JPEG preview - otherwise the original is the only
// option and the picker would be a pointless extra tap.
var quality = ShareAssetQuality.original;
final assets = ref.read(actionProvider.notifier).getShareableAssets(source);
if (shouldOfferShareQualityChoice(assets)) {
final selected = await showShareQualityPicker(context);
if (selected == null || !context.mounted) {
return;
}
quality = selected;
}
final cancelCompleter = Completer<void>();
final progress = ValueNotifier<double?>(null);
final preparingDialog = _SharePreparingDialog(progress: progress);
@@ -71,6 +86,7 @@ class ShareActionButton extends ConsumerWidget {
.shareAssets(
source,
context,
quality: quality,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: (value) => progress.value = value,
)
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/utils/share_asset.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
/// Shows a bottom sheet letting the user pick the quality used for sharing.
///
/// Resolves to the chosen [ShareAssetQuality], or `null` when the sheet is
/// dismissed without making a choice (sharing should then be aborted).
Future<ShareAssetQuality?> showShareQualityPicker(BuildContext context) {
return showModalBottomSheet<ShareAssetQuality>(
context: context,
useRootNavigator: false,
builder: (_) => const _ShareQualityPicker(),
);
}
class _ShareQualityPicker extends StatelessWidget {
const _ShareQualityPicker();
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'share_quality_title'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
),
ListTile(
leading: const Icon(Icons.high_quality_outlined),
title: Text('share_quality_original'.t(context: context)),
subtitle: Text('share_quality_original_subtitle'.t(context: context)),
onTap: () => Navigator.of(context).pop(ShareAssetQuality.original),
),
ListTile(
leading: const Icon(Icons.image_outlined),
title: Text('share_quality_preview'.t(context: context)),
subtitle: Text('share_quality_preview_subtitle'.t(context: context)),
onTap: () => Navigator.of(context).pop(ShareAssetQuality.preview),
),
const SizedBox(height: 8),
],
),
);
}
}
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -745,10 +746,12 @@ class AddToAlbumHeader extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> onCreateAlbum() async {
final selectedAssets = ref.read(multiSelectProvider).selectedAssets;
final newAlbum = await ref
.read(remoteAlbumProvider.notifier)
.createAlbumWithAssets(title: "Untitled Album", assets: selectedAssets);
.createAlbum(
title: "Untitled Album",
assetIds: ref.read(multiSelectProvider).selectedAssets.map((e) => (e as RemoteAsset).id).toList(),
);
if (newAlbum == null) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr());
@@ -5,7 +5,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
/// Pinned banner sliver that surfaces in-flight album uploads directly under
/// the album app bar. Renders nothing while the queue is empty. Tapping the
@@ -166,8 +165,6 @@ class _PendingUploadsSheet extends ConsumerWidget {
}
final failedCount = pending.where((p) => p.failed).length;
final inFlightCount = pending.length - failedCount;
final canAbort = inFlightCount > 0 && ref.watch(manualUploadCancelTokenProvider) != null;
return SafeArea(
child: Padding(
@@ -186,21 +183,7 @@ class _PendingUploadsSheet extends ConsumerWidget {
style: context.textTheme.titleMedium,
),
),
if (canAbort)
TextButton.icon(
onPressed: () {
final cancelToken = ref.read(manualUploadCancelTokenProvider);
if (cancelToken != null && !cancelToken.isCompleted) {
cancelToken.complete();
}
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
ref.read(pendingAlbumUploadsProvider(albumId).notifier).clear();
},
icon: const Icon(Icons.stop_circle_outlined, size: 18),
label: Text('cancel'.t(context: context)),
style: TextButton.styleFrom(foregroundColor: context.colorScheme.error),
)
else if (failedCount > 0)
if (failedCount > 0)
TextButton.icon(
onPressed: () => ref.read(pendingAlbumUploadsProvider(albumId).notifier).clearFailed(),
icon: const Icon(Icons.clear_rounded, size: 18),
@@ -63,19 +63,16 @@ class SheetTile extends ConsumerWidget {
subtitleWidget = null;
}
return Material(
type: MaterialType.transparency,
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
titleAlignment: ListTileTitleAlignment.center,
leading: leading,
trailing: trailing,
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
subtitle: subtitleWidget,
onTap: onTap,
),
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
titleAlignment: ListTileTitleAlignment.center,
leading: leading,
trailing: trailing,
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
subtitle: subtitleWidget,
onTap: onTap,
);
}
}
@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
@@ -23,7 +25,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -61,23 +63,37 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
);
Future<void> addToAlbum(RemoteAlbum album) async {
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
if (!context.mounted) {
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets;
if (selectedAssets.isEmpty) {
return;
}
if (!result.success) {
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
return;
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
final addedCount = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
if (selectedAssets.length != remoteAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_some_local_assets'.t(context: context),
);
}
ImmichToast.show(
context: context,
msg: result.count == 0
? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name})
: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
if (addedCount != remoteAssets.length) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {"album": album.name}),
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {"album": album.name}),
);
}
ref.read(multiSelectProvider.notifier).reset();
}
Future<void> onKeyboardExpand() {
@@ -115,10 +131,12 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand),
],
slivers: multiselect.hasRemote
? [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
]
: [],
);
}
}
@@ -1,78 +1,25 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class LocalAlbumBottomSheet extends ConsumerStatefulWidget {
class LocalAlbumBottomSheet extends ConsumerWidget {
const LocalAlbumBottomSheet({super.key});
@override
ConsumerState<LocalAlbumBottomSheet> createState() => _LocalAlbumBottomSheetState();
}
class _LocalAlbumBottomSheetState extends ConsumerState<LocalAlbumBottomSheet> {
late final DraggableScrollableController sheetController;
@override
void initState() {
super.initState();
sheetController = DraggableScrollableController();
}
@override
void dispose() {
sheetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Future<void> addToAlbum(RemoteAlbum album) async {
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
if (!context.mounted) {
return;
}
if (!result.success) {
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
return;
}
ImmichToast.show(
context: context,
msg: result.count == 0
? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name})
: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
}
Future<void> onKeyboardExpand() {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
return BaseBottomSheet(
controller: sheetController,
Widget build(BuildContext context, WidgetRef ref) {
return const BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.85,
maxChildSize: 0.4,
shouldCloseOnMinExtent: false,
actions: const [
actions: [
ShareActionButton(source: ActionSource.timeline),
DeleteLocalActionButton(source: ActionSource.timeline),
UploadActionButton(source: ActionSource.timeline),
],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand),
],
);
}
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
@@ -20,7 +21,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -55,28 +56,29 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId;
Future<void> addToAlbum(RemoteAlbum album) async {
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
if (!context.mounted) {
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets;
if (selectedAssets.isEmpty) {
return;
}
if (!result.success) {
final addedCount = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(album.id, selectedAssets.map((e) => (e as RemoteAsset).id).toList());
if (addedCount != selectedAssets.length) {
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
toastType: ToastType.error,
msg: 'add_to_album_bottom_sheet_already_exists'.t(context: context, args: {"album": album.name}),
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.t(context: context, args: {"album": album.name}),
);
return;
}
ImmichToast.show(
context: context,
msg: result.count == 0
? 'add_to_album_bottom_sheet_already_exists'.t(context: context, args: {"album": album.name})
: 'add_to_album_bottom_sheet_added'.t(context: context, args: {"album": album.name}),
);
ref.read(multiSelectProvider.notifier).reset();
}
Future<void> onKeyboardExpand() {
@@ -116,7 +118,10 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
SetAlbumCoverActionButton(source: ActionSource.timeline, albumId: widget.album.id),
],
slivers: ownsAlbum
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand)]
? [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
]
: null,
);
}
@@ -1,19 +1,19 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
class PartnerUserAvatar extends StatelessWidget {
const PartnerUserAvatar({super.key, required this.userId, required this.name});
const PartnerUserAvatar({super.key, required this.partner});
final String userId;
final String name;
final PartnerUserDto partner;
@override
Widget build(BuildContext context) {
final url = "${Store.get(StoreKey.serverEndpoint)}/users/$userId/profile-image";
final nameFirstLetter = name.isNotEmpty ? name[0] : "";
final url = "${Store.get(StoreKey.serverEndpoint)}/users/${partner.id}/profile-image";
final nameFirstLetter = partner.name.isNotEmpty ? partner.name[0] : "";
return CircleAvatar(
radius: 16,
backgroundColor: context.primaryColor.withAlpha(50),
@@ -67,11 +67,6 @@ class AlbumPendingUploadsNotifier extends AutoDisposeFamilyNotifier<List<Pending
_syncKeepAlive();
}
void clear() {
state = const [];
_syncKeepAlive();
}
void _syncKeepAlive() {
if (state.isEmpty) {
_keepAliveLink?.close();
@@ -5,15 +5,13 @@ import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/domain/utils/share_asset.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
@@ -376,52 +374,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> addToAlbum(ActionSource source, RemoteAlbum album) async {
final selected = _getAssets(source).toList(growable: false);
if (selected.isEmpty) {
return const ActionResult(count: 0, success: true);
}
final candidates = RemoteAlbumService.categorizeCandidates(selected);
final remoteIds = candidates.remoteAssetIds;
final localAssets = candidates.localAssetsToUpload;
final albumNotifier = ref.read(remoteAlbumProvider.notifier);
int addedRemote = 0;
if (remoteIds.isNotEmpty) {
try {
addedRemote = await albumNotifier.addAssets(album.id, remoteIds);
} catch (error, stack) {
_logger.severe('Failed to add assets to album ${album.id}', error, stack);
return ActionResult(count: 0, success: false, error: error.toString());
}
}
// Keep the selection available for retry if the remote add fails. Once the
// album mutation succeeds, clear timeline selection so upload overlays can render.
if (source == ActionSource.timeline) {
ref.read(multiSelectProvider.notifier).reset();
}
if (localAssets.isEmpty) {
return ActionResult(count: addedRemote, success: true);
}
final uploadResult = await upload(
source,
assets: localAssets,
onAssetUploaded: (asset, remoteId) async {
await albumNotifier.linkUploadedAssetToAlbum(album.id, asset, remoteId);
},
);
return ActionResult(
count: addedRemote + uploadResult.count,
success: uploadResult.success,
error: uploadResult.error,
);
}
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
final ids = _getRemoteIdsForSource(source);
try {
@@ -510,9 +462,13 @@ class ActionNotifier extends Notifier<void> {
}
}
/// The assets that a share action would operate on for the given [source].
List<BaseAsset> getShareableAssets(ActionSource source) => _getAssets(source).toList(growable: false);
Future<ActionResult> shareAssets(
ActionSource source,
BuildContext context, {
ShareAssetQuality quality = ShareAssetQuality.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
@@ -522,6 +478,7 @@ class ActionNotifier extends Notifier<void> {
await _service.shareAssets(
ids,
context,
quality: quality,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
@@ -544,16 +501,8 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> upload(
ActionSource source, {
List<LocalAsset>? assets,
FutureOr<void> Function(LocalAsset asset, String remoteId)? onAssetUploaded,
}) async {
Future<ActionResult> upload(ActionSource source, {List<LocalAsset>? assets}) async {
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
final assetById = {for (final a in assetsToUpload) a.id: a};
final uploadedAssetIds = <String>{};
final failedAssetIds = <String>{};
final postUploadTasks = <Future<void>>[];
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
final cancelToken = Completer<void>();
@@ -575,43 +524,16 @@ class ActionNotifier extends Notifier<void> {
},
onSuccess: (localAssetId, remoteAssetId) {
progressNotifier.remove(localAssetId);
uploadedAssetIds.add(localAssetId);
final asset = assetById[localAssetId];
final callback = onAssetUploaded;
if (asset != null && callback != null) {
postUploadTasks.add(
Future.sync(() => callback(asset, remoteAssetId)).catchError((Object error, StackTrace stack) {
failedAssetIds.add(localAssetId);
progressNotifier.setError(localAssetId);
_logger.warning('Post-upload callback failed for $localAssetId', error, stack);
}),
);
}
},
onError: (localAssetId, errorMessage) {
failedAssetIds.add(localAssetId);
progressNotifier.setError(localAssetId);
},
),
);
await Future.wait(postUploadTasks);
final successCount = uploadedAssetIds.difference(failedAssetIds).length;
final isSuccess = successCount == assetsToUpload.length && failedAssetIds.isEmpty;
return ActionResult(
count: successCount,
success: isSuccess,
error: isSuccess ? null : 'Failed to upload ${assetsToUpload.length - successCount} assets',
);
return ActionResult(count: assetsToUpload.length, success: true);
} catch (error, stack) {
_logger.severe('Failed manually upload assets', error, stack);
return ActionResult(
count: uploadedAssetIds.difference(failedAssetIds).length,
success: false,
error: error.toString(),
);
return ActionResult(count: assetsToUpload.length, success: false, error: error.toString());
} finally {
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
Future.delayed(const Duration(seconds: 2), () {
@@ -0,0 +1,86 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class PartnerNotifier extends Notifier<List<PartnerUserDto>> {
late DriftPartnerService _driftPartnerService;
@override
List<PartnerUserDto> build() {
_driftPartnerService = ref.read(driftPartnerServiceProvider);
return [];
}
Future<void> _loadPartners() async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
state = await _driftPartnerService.getSharedWith(currentUser.id);
}
Future<List<PartnerUserDto>> getPartners(String userId) async {
final partners = await _driftPartnerService.getSharedWith(userId);
state = partners;
return partners;
}
Future<void> toggleShowInTimeline(String partnerId, String userId) async {
await _driftPartnerService.toggleShowInTimeline(partnerId, userId);
await _loadPartners();
}
Future<void> addPartner(PartnerUserDto partner) async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await _driftPartnerService.addPartner(partner.id, currentUser.id);
await _loadPartners();
ref.invalidate(driftAvailablePartnerProvider);
ref.invalidate(driftSharedByPartnerProvider);
}
Future<void> removePartner(PartnerUserDto partner) async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await _driftPartnerService.removePartner(partner.id, currentUser.id);
await _loadPartners();
ref.invalidate(driftAvailablePartnerProvider);
ref.invalidate(driftSharedByPartnerProvider);
}
}
final driftAvailablePartnerProvider = FutureProvider.autoDispose<List<PartnerUserDto>>((ref) {
final currentUser = ref.watch(currentUserProvider);
if (currentUser == null) {
return [];
}
return ref.watch(driftPartnerServiceProvider).getAvailablePartners(currentUser.id);
});
final driftSharedByPartnerProvider = FutureProvider.autoDispose<List<PartnerUserDto>>((ref) {
final currentUser = ref.watch(currentUserProvider);
if (currentUser == null) {
return [];
}
return ref.watch(driftPartnerServiceProvider).getSharedBy(currentUser.id);
});
final driftSharedWithPartnerProvider = FutureProvider.autoDispose<List<PartnerUserDto>>((ref) {
final currentUser = ref.watch(currentUserProvider);
if (currentUser == null) {
return [];
}
return ref.watch(driftPartnerServiceProvider).getSharedWith(currentUser.id);
});
@@ -9,7 +9,6 @@ import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
@@ -208,22 +207,6 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
return added;
}
/// Links a freshly-uploaded local asset to an album using its new remote ID,
/// upserting a placeholder remote asset row so the local DB join survives
/// until the next sync catches up.
Future<int> linkUploadedAssetToAlbum(String albumId, LocalAsset source, String remoteId) async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
throw Exception('User not logged in');
}
final added = await _remoteAlbumService.linkUploadedAssetToAlbum(albumId, remoteId, currentUser, source);
if (added > 0) {
await _refreshAlbumInState(albumId);
}
return added;
}
/// Adds a heterogeneous asset selection to an album. Already-remote assets
/// are linked immediately; local-only assets are queued in
/// [pendingAlbumUploadsProvider] (so the album page can show them with
@@ -238,18 +221,11 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
final pendingNotifier = ref.read(pendingAlbumUploadsProvider(albumId).notifier);
pendingNotifier.enqueue(candidates.localAssetsToUpload);
Completer<void>? cancelToken;
if (candidates.localAssetsToUpload.isNotEmpty) {
cancelToken = Completer<void>();
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
}
try {
final added = await _remoteAlbumService.addAssetsToAlbum(
albumId: albumId,
uploader: currentUser,
candidates: candidates,
cancelToken: cancelToken,
uploadCallbacks: UploadCallbacks(
onProgress: (localAssetId, _, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
@@ -269,15 +245,6 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
}
_logger.severe('Failed to add assets to album $albumId', error, stack);
rethrow;
} finally {
if (cancelToken != null) {
if (cancelToken.isCompleted) {
pendingNotifier.clear();
}
if (ref.read(manualUploadCancelTokenProvider) == cancelToken) {
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
}
}
}
}
@@ -1,16 +1,15 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
final userRepositoryProvider = Provider((ref) => UserRepository(ref.watch(driftProvider)));
final userApiRepositoryProvider = Provider((ref) => UserApiRepository(ref.watch(apiServiceProvider).usersApi));
final userServiceProvider = Provider(
@@ -20,12 +19,13 @@ final userServiceProvider = Provider(
),
);
final partnerRepositoryProvider = Provider<PartnerRepository>((ref) => PartnerRepository(ref.watch(driftProvider)));
final partnerServiceProvider = Provider<PartnerService>(
(ref) => PartnerService(
ref.watch(userRepositoryProvider),
ref.watch(partnerRepositoryProvider),
ref.watch(partnerApiRepositoryProvider),
),
/// Drifts
final driftPartnerRepositoryProvider = Provider<DriftPartnerRepository>(
(ref) => DriftPartnerRepository(ref.watch(driftProvider)),
);
final driftPartnerServiceProvider = Provider<DriftPartnerService>(
(ref) => DriftPartnerService(ref.watch(driftPartnerRepositoryProvider), ref.watch(partnerApiRepositoryProvider)),
);
final partnerUsersProvider = NotifierProvider<PartnerNotifier, List<PartnerUserDto>>(PartnerNotifier.new);
@@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/utils/share_asset.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
@@ -108,6 +109,7 @@ class AssetMediaRepository {
Future<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
ShareAssetQuality quality = ShareAssetQuality.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) async {
@@ -136,13 +138,16 @@ class AssetMediaRepository {
return 0;
}
final localId = (asset is LocalAsset)
? asset.id
: asset is RemoteAsset
? asset.localId
: null;
if (localId != null && !asset.isEdited) {
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
final source = resolveShareSource(asset, quality);
if (source == null) {
_log.warning("Asset has no shareable source: $asset");
processedAssets++;
updateProgress();
continue;
}
if (source.isLocal) {
File? f = await AssetEntity(id: source.localId!, width: 1, height: 1, typeInt: 0).originFile;
downloadedXFiles.add(XFile(f!.path));
processedAssets++;
updateProgress();
@@ -150,19 +155,16 @@ class AssetMediaRepository {
tempFiles.add(f);
}
} else {
final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId;
if (remoteId == null) {
_log.warning("Asset has no remote ID for sharing: $asset");
processedAssets++;
updateProgress();
continue;
}
final remoteId = source.remoteId!;
final taskId = 'share-$remoteId-${DateTime.now().microsecondsSinceEpoch}';
final sanitizedFilename = asset.name.replaceAll(RegExp(r'[\\/]'), '_');
final sanitizedFilename = shareFilename(asset, source);
final url = source.isPreview
? getPreviewUrlForRemoteId(remoteId, edited: asset.isEdited)
: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited);
final task = DownloadTask(
taskId: taskId,
url: getOriginalUrlForRemoteId(remoteId, edited: asset.isEdited),
url: url,
headers: ApiService.getRequestHeaders(),
filename: sanitizedFilename,
baseDirectory: BaseDirectory.temporary,
+8 -10
View File
@@ -1,40 +1,38 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
final authRepositoryProvider = Provider<AuthRepository>(
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(settingsProvider)),
(ref) => AuthRepository(ref.watch(driftProvider), ref.watch(appConfigProvider)),
);
class AuthRepository {
final Drift _drift;
final SettingsRepository _settings;
final AppConfig _config;
const AuthRepository(this._drift, this._settings);
const AuthRepository(this._drift, this._config);
Future<void> clearLocalData() async {
await SyncStreamRepository(_drift).reset();
}
bool getEndpointSwitchingFeature() {
return _settings.appConfig.network.autoEndpointSwitching;
return _config.network.autoEndpointSwitching;
}
String? getPreferredWifiName() {
return _settings.appConfig.network.preferredWifiName;
return _config.network.preferredWifiName;
}
String? getLocalEndpoint() {
return _settings.appConfig.network.localEndpoint;
return _config.network.localEndpoint;
}
List<AuxilaryEndpoint> getExternalEndpointList() {
return _settings.appConfig.network.externalEndpointList
.map((url) => AuxilaryEndpoint(url: url, status: .valid))
.toList();
return _config.network.externalEndpointList.map((url) => AuxilaryEndpoint(url: url, status: .valid)).toList();
}
}
+1 -1
View File
@@ -57,8 +57,8 @@ import 'package:immich_mobile/presentation/pages/drift_people_collection.page.da
import 'package:immich_mobile/presentation/pages/drift_person.page.dart';
import 'package:immich_mobile/presentation/pages/drift_place.page.dart';
import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart';
import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
+2 -2
View File
@@ -827,7 +827,7 @@ class DriftPartnerDetailRoute
extends PageRouteInfo<DriftPartnerDetailRouteArgs> {
DriftPartnerDetailRoute({
Key? key,
required Partner partner,
required PartnerUserDto partner,
List<PageRouteInfo>? children,
}) : super(
DriftPartnerDetailRoute.name,
@@ -851,7 +851,7 @@ class DriftPartnerDetailRouteArgs {
final Key? key;
final Partner partner;
final PartnerUserDto partner;
@override
String toString() {
+3
View File
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/domain/utils/share_asset.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@@ -272,12 +273,14 @@ class ActionService {
Future<int> shareAssets(
List<BaseAsset> assets,
BuildContext context, {
ShareAssetQuality quality = ShareAssetQuality.original,
Completer<void>? cancelCompleter,
void Function(double progress)? onAssetDownloadProgress,
}) {
return _assetMediaRepository.shareAssets(
assets,
context,
quality: quality,
cancelCompleter: cancelCompleter,
onAssetDownloadProgress: onAssetDownloadProgress,
);
+4
View File
@@ -16,6 +16,10 @@ String getThumbnailUrlForRemoteId(
return thumbhash != null ? '$url&c=${Uri.encodeComponent(thumbhash)}' : url;
}
String getPreviewUrlForRemoteId(final String id, {bool edited = true}) {
return getThumbnailUrlForRemoteId(id, type: AssetMediaSize.preview, edited: edited);
}
String getPlaybackUrlForRemoteId(final String id) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?';
}
+20 -52
View File
@@ -1,42 +1,36 @@
enum SemVerType { major, minor, patch, prerelease }
enum SemVerType { major, minor, patch }
class SemVer {
final int major;
final int minor;
final int patch;
final int? prerelease;
const SemVer({required this.major, required this.minor, required this.patch, this.prerelease});
const SemVer({required this.major, required this.minor, required this.patch});
@override
String toString() {
return '$major.$minor.$patch${prerelease == null ? '' : '-rc.$prerelease'}';
return '$major.$minor.$patch';
}
SemVer copyWith({int? major, int? minor, int? patch, int? prerelease}) {
return SemVer(
major: major ?? this.major,
minor: minor ?? this.minor,
patch: patch ?? this.patch,
prerelease: prerelease ?? this.prerelease,
);
SemVer copyWith({int? major, int? minor, int? patch}) {
return SemVer(major: major ?? this.major, minor: minor ?? this.minor, patch: patch ?? this.patch);
}
static final _pattern = RegExp(r'^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?(?:[-+].*)?$', caseSensitive: false);
factory SemVer.fromString(String version) {
final match = _pattern.firstMatch(version);
if (match == null) {
if (version.toLowerCase().startsWith("v")) {
version = version.substring(1);
}
final parts = version.split("-")[0].split('.');
if (parts.length != 3) {
throw FormatException('Invalid semantic version string: $version');
}
final prerelease = match.group(4);
return SemVer(
major: int.parse(match.group(1)!),
minor: int.parse(match.group(2)!),
patch: int.parse(match.group(3)!),
prerelease: prerelease == null ? null : int.parse(prerelease),
);
try {
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2]));
} catch (e) {
throw FormatException('Invalid semantic version string: $version');
}
}
bool operator >(SemVer other) {
@@ -46,10 +40,7 @@ class SemVer {
if (minor != other.minor) {
return minor > other.minor;
}
if (patch != other.patch) {
return patch > other.patch;
}
return _comparePrerelease(other) > 0;
return patch > other.patch;
}
bool operator <(SemVer other) {
@@ -59,23 +50,7 @@ class SemVer {
if (minor != other.minor) {
return minor < other.minor;
}
if (patch != other.patch) {
return patch < other.patch;
}
return _comparePrerelease(other) < 0;
}
int _comparePrerelease(SemVer other) {
if (prerelease == other.prerelease) {
return 0;
}
if (prerelease == null) {
return 1;
}
if (other.prerelease == null) {
return -1;
}
return prerelease!.compareTo(other.prerelease!);
return patch < other.patch;
}
bool operator >=(SemVer other) {
@@ -92,11 +67,7 @@ class SemVer {
return true;
}
return other is SemVer &&
other.major == major &&
other.minor == minor &&
other.patch == patch &&
other.prerelease == prerelease;
return other is SemVer && other.major == major && other.minor == minor && other.patch == patch;
}
SemVerType? differenceType(SemVer other) {
@@ -109,13 +80,10 @@ class SemVer {
if (patch != other.patch) {
return SemVerType.patch;
}
if (prerelease != other.prerelease) {
return SemVerType.prerelease;
}
return null;
}
@override
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode ^ prerelease.hashCode;
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode;
}
@@ -50,7 +50,9 @@ class AppBarServerInfo extends HookConsumerWidget {
divider,
_ServerInfoItem(
label: "server_version".tr(),
text: serverInfoState.serverVersion.major > 0 ? "${serverInfoState.serverVersion}" : "--",
text: serverInfoState.serverVersion.major > 0
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
: "--",
),
divider,
_ServerInfoItem(label: "server_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true),
@@ -58,7 +60,9 @@ class AppBarServerInfo extends HookConsumerWidget {
divider,
_ServerInfoItem(
label: "latest_version".tr(),
text: serverInfoState.latestVersion!.major > 0 ? "${serverInfoState.latestVersion!}" : "--",
text: serverInfoState.latestVersion!.major > 0
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
: "--",
tooltip: true,
icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate
? const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12)
-8
View File
@@ -103,16 +103,12 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**deleteBulkAssetMetadata**](doc//AssetsApi.md#deletebulkassetmetadata) | **DELETE** /assets/metadata | Delete asset metadata
*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | Download original asset
*AssetsApi* | [**editAsset**](doc//AssetsApi.md#editasset) | **PUT** /assets/{id}/edits | Apply edits to an existing asset
*AssetsApi* | [**endSession**](doc//AssetsApi.md#endsession) | **DELETE** /assets/{id}/video/stream/{sessionId} | End HLS streaming session
*AssetsApi* | [**getAssetEdits**](doc//AssetsApi.md#getassetedits) | **GET** /assets/{id}/edits | Retrieve edits for an existing asset
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | Retrieve an asset
*AssetsApi* | [**getAssetMetadata**](doc//AssetsApi.md#getassetmetadata) | **GET** /assets/{id}/metadata | Get asset metadata
*AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} | Retrieve asset metadata by key
*AssetsApi* | [**getAssetOcr**](doc//AssetsApi.md#getassetocr) | **GET** /assets/{id}/ocr | Retrieve asset OCR data
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | Get asset statistics
*AssetsApi* | [**getMainPlaylist**](doc//AssetsApi.md#getmainplaylist) | **GET** /assets/{id}/video/stream/main.m3u8 | Get HLS main playlist
*AssetsApi* | [**getMediaPlaylist**](doc//AssetsApi.md#getmediaplaylist) | **GET** /assets/{id}/video/stream/{sessionId}/{variantIndex}/playlist.m3u8 | Get HLS media playlist
*AssetsApi* | [**getSegment**](doc//AssetsApi.md#getsegment) | **GET** /assets/{id}/video/stream/{sessionId}/{variantIndex}/{filename} | Get HLS segment or init file
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video
*AssetsApi* | [**removeAssetEdits**](doc//AssetsApi.md#removeassetedits) | **DELETE** /assets/{id}/edits | Remove edits from an existing asset
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job
@@ -517,9 +513,6 @@ Class | Method | HTTP request | Description
- [RatingsUpdate](doc//RatingsUpdate.md)
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)
- [ReleaseChannel](doc//ReleaseChannel.md)
- [ReleaseEventV1](doc//ReleaseEventV1.md)
- [ReleaseType](doc//ReleaseType.md)
- [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
- [RotateParameters](doc//RotateParameters.md)
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
@@ -604,7 +597,6 @@ Class | Method | HTTP request | Description
- [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md)
- [SystemConfigDto](doc//SystemConfigDto.md)
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
- [SystemConfigFFmpegRealtimeDto](doc//SystemConfigFFmpegRealtimeDto.md)
- [SystemConfigFacesDto](doc//SystemConfigFacesDto.md)
- [SystemConfigGeneratedFullsizeImageDto](doc//SystemConfigGeneratedFullsizeImageDto.md)
- [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md)
-4
View File
@@ -258,9 +258,6 @@ part 'model/ratings_response.dart';
part 'model/ratings_update.dart';
part 'model/reaction_level.dart';
part 'model/reaction_type.dart';
part 'model/release_channel.dart';
part 'model/release_event_v1.dart';
part 'model/release_type.dart';
part 'model/reverse_geocoding_state_response_dto.dart';
part 'model/rotate_parameters.dart';
part 'model/search_album_response_dto.dart';
@@ -345,7 +342,6 @@ part 'model/sync_user_v1.dart';
part 'model/system_config_backups_dto.dart';
part 'model/system_config_dto.dart';
part 'model/system_config_f_fmpeg_dto.dart';
part 'model/system_config_f_fmpeg_realtime_dto.dart';
part 'model/system_config_faces_dto.dart';
part 'model/system_config_generated_fullsize_image_dto.dart';
part 'model/system_config_generated_image_dto.dart';
-314
View File
@@ -423,76 +423,6 @@ class AssetsApi {
return null;
}
/// End HLS streaming session
///
/// Releases server resources for the streaming session.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> endSessionWithHttpInfo(String id, String sessionId, { String? key, String? slug, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/{sessionId}'
.replaceAll('{id}', id)
.replaceAll('{sessionId}', sessionId);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// End HLS streaming session
///
/// Releases server resources for the streaming session.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<void> endSession(String id, String sessionId, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await endSessionWithHttpInfo(id, sessionId, key: key, slug: slug, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Retrieve edits for an existing asset
///
/// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.
@@ -892,250 +822,6 @@ class AssetsApi {
return null;
}
/// Get HLS main playlist
///
/// Returns an HLS main playlist with all available variants for the asset.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> getMainPlaylistWithHttpInfo(String id, { String? key, String? slug, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/main.m3u8'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Get HLS main playlist
///
/// Returns an HLS main playlist with all available variants for the asset.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<String?> getMainPlaylist(String id, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await getMainPlaylistWithHttpInfo(id, key: key, slug: slug, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'String',) as String;
}
return null;
}
/// Get HLS media playlist
///
/// Returns an HLS media playlist for one variant of the streaming session.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [int] variantIndex (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> getMediaPlaylistWithHttpInfo(String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/{sessionId}/{variantIndex}/playlist.m3u8'
.replaceAll('{id}', id)
.replaceAll('{sessionId}', sessionId)
.replaceAll('{variantIndex}', variantIndex.toString());
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Get HLS media playlist
///
/// Returns an HLS media playlist for one variant of the streaming session.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [int] variantIndex (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<String?> getMediaPlaylist(String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await getMediaPlaylistWithHttpInfo(id, sessionId, variantIndex, key: key, slug: slug, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'String',) as String;
}
return null;
}
/// Get HLS segment or init file
///
/// Streams an HLS init segment (init.mp4) or media segment (seg_N.m4s).
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] filename (required):
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [int] variantIndex (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> getSegmentWithHttpInfo(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/{sessionId}/{variantIndex}/{filename}'
.replaceAll('{filename}', filename)
.replaceAll('{id}', id)
.replaceAll('{sessionId}', sessionId)
.replaceAll('{variantIndex}', variantIndex.toString());
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Get HLS segment or init file
///
/// Streams an HLS init segment (init.mp4) or media segment (seg_N.m4s).
///
/// Parameters:
///
/// * [String] filename (required):
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [int] variantIndex (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<MultipartFile?> getSegment(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await getSegmentWithHttpInfo(filename, id, sessionId, variantIndex, key: key, slug: slug, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
}
return null;
}
/// Play asset video
///
/// Streams the video file for the specified asset. This endpoint also supports byte range requests.
-8
View File
@@ -563,12 +563,6 @@ class ApiClient {
return ReactionLevelTypeTransformer().decode(value);
case 'ReactionType':
return ReactionTypeTypeTransformer().decode(value);
case 'ReleaseChannel':
return ReleaseChannelTypeTransformer().decode(value);
case 'ReleaseEventV1':
return ReleaseEventV1.fromJson(value);
case 'ReleaseType':
return ReleaseTypeTypeTransformer().decode(value);
case 'ReverseGeocodingStateResponseDto':
return ReverseGeocodingStateResponseDto.fromJson(value);
case 'RotateParameters':
@@ -737,8 +731,6 @@ class ApiClient {
return SystemConfigDto.fromJson(value);
case 'SystemConfigFFmpegDto':
return SystemConfigFFmpegDto.fromJson(value);
case 'SystemConfigFFmpegRealtimeDto':
return SystemConfigFFmpegRealtimeDto.fromJson(value);
case 'SystemConfigFacesDto':
return SystemConfigFacesDto.fromJson(value);
case 'SystemConfigGeneratedFullsizeImageDto':
-6
View File
@@ -157,12 +157,6 @@ String parameterToString(dynamic value) {
if (value is ReactionType) {
return ReactionTypeTypeTransformer().encode(value).toString();
}
if (value is ReleaseChannel) {
return ReleaseChannelTypeTransformer().encode(value).toString();
}
if (value is ReleaseType) {
return ReleaseTypeTypeTransformer().encode(value).toString();
}
if (value is SearchSuggestionType) {
return SearchSuggestionTypeTypeTransformer().encode(value).toString();
}
-3
View File
@@ -52,7 +52,6 @@ class JobName {
static const librarySyncFilesQueueAll = JobName._(r'LibrarySyncFilesQueueAll');
static const librarySyncFiles = JobName._(r'LibrarySyncFiles');
static const libraryScanQueueAll = JobName._(r'LibraryScanQueueAll');
static const hlsSessionCleanup = JobName._(r'HlsSessionCleanup');
static const memoryCleanup = JobName._(r'MemoryCleanup');
static const memoryGenerate = JobName._(r'MemoryGenerate');
static const notificationsCleanup = JobName._(r'NotificationsCleanup');
@@ -111,7 +110,6 @@ class JobName {
librarySyncFilesQueueAll,
librarySyncFiles,
libraryScanQueueAll,
hlsSessionCleanup,
memoryCleanup,
memoryGenerate,
notificationsCleanup,
@@ -205,7 +203,6 @@ class JobNameTypeTransformer {
case r'LibrarySyncFilesQueueAll': return JobName.librarySyncFilesQueueAll;
case r'LibrarySyncFiles': return JobName.librarySyncFiles;
case r'LibraryScanQueueAll': return JobName.libraryScanQueueAll;
case r'HlsSessionCleanup': return JobName.hlsSessionCleanup;
case r'MemoryCleanup': return JobName.memoryCleanup;
case r'MemoryGenerate': return JobName.memoryGenerate;
case r'NotificationsCleanup': return JobName.notificationsCleanup;
+1 -22
View File
@@ -14,52 +14,32 @@ class PeopleResponse {
/// Returns a new [PeopleResponse] instance.
PeopleResponse({
required this.enabled,
this.minimumFaces,
required this.sidebarWeb,
});
/// Whether people are enabled
bool enabled;
/// People face threshold
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? minimumFaces;
/// Whether people appear in web sidebar
bool sidebarWeb;
@override
bool operator ==(Object other) => identical(this, other) || other is PeopleResponse &&
other.enabled == enabled &&
other.minimumFaces == minimumFaces &&
other.sidebarWeb == sidebarWeb;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(enabled.hashCode) +
(minimumFaces == null ? 0 : minimumFaces!.hashCode) +
(sidebarWeb.hashCode);
@override
String toString() => 'PeopleResponse[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]';
String toString() => 'PeopleResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'enabled'] = this.enabled;
if (this.minimumFaces != null) {
json[r'minimumFaces'] = this.minimumFaces;
} else {
// json[r'minimumFaces'] = null;
}
json[r'sidebarWeb'] = this.sidebarWeb;
return json;
}
@@ -74,7 +54,6 @@ class PeopleResponse {
return PeopleResponse(
enabled: mapValueOfType<bool>(json, r'enabled')!,
minimumFaces: mapValueOfType<int>(json, r'minimumFaces'),
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb')!,
);
}
+1 -22
View File
@@ -14,7 +14,6 @@ class PeopleUpdate {
/// Returns a new [PeopleUpdate] instance.
PeopleUpdate({
this.enabled,
this.minimumFaces,
this.sidebarWeb,
});
@@ -27,18 +26,6 @@ class PeopleUpdate {
///
bool? enabled;
/// People face threshold
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? minimumFaces;
/// Whether people appear in web sidebar
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -51,18 +38,16 @@ class PeopleUpdate {
@override
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdate &&
other.enabled == enabled &&
other.minimumFaces == minimumFaces &&
other.sidebarWeb == sidebarWeb;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(enabled == null ? 0 : enabled!.hashCode) +
(minimumFaces == null ? 0 : minimumFaces!.hashCode) +
(sidebarWeb == null ? 0 : sidebarWeb!.hashCode);
@override
String toString() => 'PeopleUpdate[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]';
String toString() => 'PeopleUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -71,11 +56,6 @@ class PeopleUpdate {
} else {
// json[r'enabled'] = null;
}
if (this.minimumFaces != null) {
json[r'minimumFaces'] = this.minimumFaces;
} else {
// json[r'minimumFaces'] = null;
}
if (this.sidebarWeb != null) {
json[r'sidebarWeb'] = this.sidebarWeb;
} else {
@@ -94,7 +74,6 @@ class PeopleUpdate {
return PeopleUpdate(
enabled: mapValueOfType<bool>(json, r'enabled'),
minimumFaces: mapValueOfType<int>(json, r'minimumFaces'),
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb'),
);
}
-85
View File
@@ -1,85 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Release channel
class ReleaseChannel {
/// Instantiate a new enum with the provided [value].
const ReleaseChannel._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const stable = ReleaseChannel._(r'stable');
static const releaseCandidate = ReleaseChannel._(r'releaseCandidate');
/// List of all possible values in this [enum][ReleaseChannel].
static const values = <ReleaseChannel>[
stable,
releaseCandidate,
];
static ReleaseChannel? fromJson(dynamic value) => ReleaseChannelTypeTransformer().decode(value);
static List<ReleaseChannel> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseChannel>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseChannel.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ReleaseChannel] to String,
/// and [decode] dynamic data back to [ReleaseChannel].
class ReleaseChannelTypeTransformer {
factory ReleaseChannelTypeTransformer() => _instance ??= const ReleaseChannelTypeTransformer._();
const ReleaseChannelTypeTransformer._();
String encode(ReleaseChannel data) => data.value;
/// Decodes a [dynamic value][data] to a ReleaseChannel.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ReleaseChannel? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'stable': return ReleaseChannel.stable;
case r'releaseCandidate': return ReleaseChannel.releaseCandidate;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ReleaseChannelTypeTransformer] instance.
static ReleaseChannelTypeTransformer? _instance;
}
-133
View File
@@ -1,133 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ReleaseEventV1 {
/// Returns a new [ReleaseEventV1] instance.
ReleaseEventV1({
required this.checkedAt,
required this.isAvailable,
required this.releaseVersion,
required this.serverVersion,
required this.type,
});
/// When the server last checked for a latest version. As an ISO timestamp
String checkedAt;
/// Whether a new version is available
bool isAvailable;
ServerVersionResponseDto releaseVersion;
ServerVersionResponseDto serverVersion;
ReleaseType type;
@override
bool operator ==(Object other) => identical(this, other) || other is ReleaseEventV1 &&
other.checkedAt == checkedAt &&
other.isAvailable == isAvailable &&
other.releaseVersion == releaseVersion &&
other.serverVersion == serverVersion &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(checkedAt.hashCode) +
(isAvailable.hashCode) +
(releaseVersion.hashCode) +
(serverVersion.hashCode) +
(type.hashCode);
@override
String toString() => 'ReleaseEventV1[checkedAt=$checkedAt, isAvailable=$isAvailable, releaseVersion=$releaseVersion, serverVersion=$serverVersion, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'checkedAt'] = this.checkedAt;
json[r'isAvailable'] = this.isAvailable;
json[r'releaseVersion'] = this.releaseVersion;
json[r'serverVersion'] = this.serverVersion;
json[r'type'] = this.type;
return json;
}
/// Returns a new [ReleaseEventV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ReleaseEventV1? fromJson(dynamic value) {
upgradeDto(value, "ReleaseEventV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return ReleaseEventV1(
checkedAt: mapValueOfType<String>(json, r'checkedAt')!,
isAvailable: mapValueOfType<bool>(json, r'isAvailable')!,
releaseVersion: ServerVersionResponseDto.fromJson(json[r'releaseVersion'])!,
serverVersion: ServerVersionResponseDto.fromJson(json[r'serverVersion'])!,
type: ReleaseType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<ReleaseEventV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseEventV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseEventV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ReleaseEventV1> mapFromJson(dynamic json) {
final map = <String, ReleaseEventV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ReleaseEventV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ReleaseEventV1-objects as value to a dart map
static Map<String, List<ReleaseEventV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ReleaseEventV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ReleaseEventV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'checkedAt',
'isAvailable',
'releaseVersion',
'serverVersion',
'type',
};
}
-100
View File
@@ -1,100 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ReleaseType {
/// Instantiate a new enum with the provided [value].
const ReleaseType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const major = ReleaseType._(r'major');
static const premajor = ReleaseType._(r'premajor');
static const minor = ReleaseType._(r'minor');
static const preminor = ReleaseType._(r'preminor');
static const patch_ = ReleaseType._(r'patch');
static const prepatch = ReleaseType._(r'prepatch');
static const prerelease = ReleaseType._(r'prerelease');
/// List of all possible values in this [enum][ReleaseType].
static const values = <ReleaseType>[
major,
premajor,
minor,
preminor,
patch_,
prepatch,
prerelease,
];
static ReleaseType? fromJson(dynamic value) => ReleaseTypeTypeTransformer().decode(value);
static List<ReleaseType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ReleaseType] to String,
/// and [decode] dynamic data back to [ReleaseType].
class ReleaseTypeTypeTransformer {
factory ReleaseTypeTypeTransformer() => _instance ??= const ReleaseTypeTypeTransformer._();
const ReleaseTypeTypeTransformer._();
String encode(ReleaseType data) => data.value;
/// Decodes a [dynamic value][data] to a ReleaseType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ReleaseType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'major': return ReleaseType.major;
case r'premajor': return ReleaseType.premajor;
case r'minor': return ReleaseType.minor;
case r'preminor': return ReleaseType.preminor;
case r'patch': return ReleaseType.patch_;
case r'prepatch': return ReleaseType.prepatch;
case r'prerelease': return ReleaseType.prerelease;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ReleaseTypeTypeTransformer] instance.
static ReleaseTypeTypeTransformer? _instance;
}
+1 -13
View File
@@ -20,7 +20,6 @@ class ServerConfigDto {
required this.maintenanceMode,
required this.mapDarkStyleUrl,
required this.mapLightStyleUrl,
required this.minFaces,
required this.oauthButtonText,
required this.publicUsers,
required this.trashDays,
@@ -48,12 +47,6 @@ class ServerConfigDto {
/// Map light style URL
String mapLightStyleUrl;
/// People min faces server default
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int minFaces;
/// OAuth button text
String oauthButtonText;
@@ -81,7 +74,6 @@ class ServerConfigDto {
other.maintenanceMode == maintenanceMode &&
other.mapDarkStyleUrl == mapDarkStyleUrl &&
other.mapLightStyleUrl == mapLightStyleUrl &&
other.minFaces == minFaces &&
other.oauthButtonText == oauthButtonText &&
other.publicUsers == publicUsers &&
other.trashDays == trashDays &&
@@ -97,14 +89,13 @@ class ServerConfigDto {
(maintenanceMode.hashCode) +
(mapDarkStyleUrl.hashCode) +
(mapLightStyleUrl.hashCode) +
(minFaces.hashCode) +
(oauthButtonText.hashCode) +
(publicUsers.hashCode) +
(trashDays.hashCode) +
(userDeleteDelay.hashCode);
@override
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, minFaces=$minFaces, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -115,7 +106,6 @@ class ServerConfigDto {
json[r'maintenanceMode'] = this.maintenanceMode;
json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl;
json[r'mapLightStyleUrl'] = this.mapLightStyleUrl;
json[r'minFaces'] = this.minFaces;
json[r'oauthButtonText'] = this.oauthButtonText;
json[r'publicUsers'] = this.publicUsers;
json[r'trashDays'] = this.trashDays;
@@ -139,7 +129,6 @@ class ServerConfigDto {
maintenanceMode: mapValueOfType<bool>(json, r'maintenanceMode')!,
mapDarkStyleUrl: mapValueOfType<String>(json, r'mapDarkStyleUrl')!,
mapLightStyleUrl: mapValueOfType<String>(json, r'mapLightStyleUrl')!,
minFaces: mapValueOfType<int>(json, r'minFaces')!,
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
publicUsers: mapValueOfType<bool>(json, r'publicUsers')!,
trashDays: mapValueOfType<int>(json, r'trashDays')!,
@@ -198,7 +187,6 @@ class ServerConfigDto {
'maintenanceMode',
'mapDarkStyleUrl',
'mapLightStyleUrl',
'minFaces',
'oauthButtonText',
'publicUsers',
'trashDays',
+1 -10
View File
@@ -23,7 +23,6 @@ class ServerFeaturesDto {
required this.oauthAutoLaunch,
required this.ocr,
required this.passwordLogin,
required this.realtimeTranscoding,
required this.reverseGeocoding,
required this.search,
required this.sidecar,
@@ -61,9 +60,6 @@ class ServerFeaturesDto {
/// Whether password login is enabled
bool passwordLogin;
/// Whether real-time transcoding is enabled
bool realtimeTranscoding;
/// Whether reverse geocoding is enabled
bool reverseGeocoding;
@@ -91,7 +87,6 @@ class ServerFeaturesDto {
other.oauthAutoLaunch == oauthAutoLaunch &&
other.ocr == ocr &&
other.passwordLogin == passwordLogin &&
other.realtimeTranscoding == realtimeTranscoding &&
other.reverseGeocoding == reverseGeocoding &&
other.search == search &&
other.sidecar == sidecar &&
@@ -111,7 +106,6 @@ class ServerFeaturesDto {
(oauthAutoLaunch.hashCode) +
(ocr.hashCode) +
(passwordLogin.hashCode) +
(realtimeTranscoding.hashCode) +
(reverseGeocoding.hashCode) +
(search.hashCode) +
(sidecar.hashCode) +
@@ -119,7 +113,7 @@ class ServerFeaturesDto {
(trash.hashCode);
@override
String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, ocr=$ocr, passwordLogin=$passwordLogin, realtimeTranscoding=$realtimeTranscoding, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, ocr=$ocr, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -133,7 +127,6 @@ class ServerFeaturesDto {
json[r'oauthAutoLaunch'] = this.oauthAutoLaunch;
json[r'ocr'] = this.ocr;
json[r'passwordLogin'] = this.passwordLogin;
json[r'realtimeTranscoding'] = this.realtimeTranscoding;
json[r'reverseGeocoding'] = this.reverseGeocoding;
json[r'search'] = this.search;
json[r'sidecar'] = this.sidecar;
@@ -161,7 +154,6 @@ class ServerFeaturesDto {
oauthAutoLaunch: mapValueOfType<bool>(json, r'oauthAutoLaunch')!,
ocr: mapValueOfType<bool>(json, r'ocr')!,
passwordLogin: mapValueOfType<bool>(json, r'passwordLogin')!,
realtimeTranscoding: mapValueOfType<bool>(json, r'realtimeTranscoding')!,
reverseGeocoding: mapValueOfType<bool>(json, r'reverseGeocoding')!,
search: mapValueOfType<bool>(json, r'search')!,
sidecar: mapValueOfType<bool>(json, r'sidecar')!,
@@ -224,7 +216,6 @@ class ServerFeaturesDto {
'oauthAutoLaunch',
'ocr',
'passwordLogin',
'realtimeTranscoding',
'reverseGeocoding',
'search',
'sidecar',
+6 -22
View File
@@ -16,61 +16,47 @@ class ServerVersionResponseDto {
required this.major,
required this.minor,
required this.patch_,
required this.prerelease,
});
/// Major version number
///
/// Minimum value: 0
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int major;
/// Minor version number
///
/// Minimum value: 0
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int minor;
/// Patch version number
///
/// Minimum value: 0
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int patch_;
/// Pre-release version number
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? prerelease;
@override
bool operator ==(Object other) => identical(this, other) || other is ServerVersionResponseDto &&
other.major == major &&
other.minor == minor &&
other.patch_ == patch_ &&
other.prerelease == prerelease;
other.patch_ == patch_;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(major.hashCode) +
(minor.hashCode) +
(patch_.hashCode) +
(prerelease == null ? 0 : prerelease!.hashCode);
(patch_.hashCode);
@override
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_, prerelease=$prerelease]';
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'major'] = this.major;
json[r'minor'] = this.minor;
json[r'patch'] = this.patch_;
if (this.prerelease != null) {
json[r'prerelease'] = this.prerelease;
} else {
// json[r'prerelease'] = null;
}
return json;
}
@@ -86,7 +72,6 @@ class ServerVersionResponseDto {
major: mapValueOfType<int>(json, r'major')!,
minor: mapValueOfType<int>(json, r'minor')!,
patch_: mapValueOfType<int>(json, r'patch')!,
prerelease: mapValueOfType<int>(json, r'prerelease'),
);
}
return null;
@@ -137,7 +122,6 @@ class ServerVersionResponseDto {
'major',
'minor',
'patch',
'prerelease',
};
}
+1 -9
View File
@@ -25,7 +25,6 @@ class SystemConfigFFmpegDto {
required this.maxBitrate,
required this.preferredHwDevice,
required this.preset,
required this.realtime,
required this.refs,
required this.targetAudioCodec,
required this.targetResolution,
@@ -80,8 +79,6 @@ class SystemConfigFFmpegDto {
/// Preset
String preset;
SystemConfigFFmpegRealtimeDto realtime;
/// References
///
/// Minimum value: 0
@@ -125,7 +122,6 @@ class SystemConfigFFmpegDto {
other.maxBitrate == maxBitrate &&
other.preferredHwDevice == preferredHwDevice &&
other.preset == preset &&
other.realtime == realtime &&
other.refs == refs &&
other.targetAudioCodec == targetAudioCodec &&
other.targetResolution == targetResolution &&
@@ -151,7 +147,6 @@ class SystemConfigFFmpegDto {
(maxBitrate.hashCode) +
(preferredHwDevice.hashCode) +
(preset.hashCode) +
(realtime.hashCode) +
(refs.hashCode) +
(targetAudioCodec.hashCode) +
(targetResolution.hashCode) +
@@ -163,7 +158,7 @@ class SystemConfigFFmpegDto {
(twoPass.hashCode);
@override
String toString() => 'SystemConfigFFmpegDto[accel=$accel, accelDecode=$accelDecode, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedContainers=$acceptedContainers, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, preferredHwDevice=$preferredHwDevice, preset=$preset, realtime=$realtime, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]';
String toString() => 'SystemConfigFFmpegDto[accel=$accel, accelDecode=$accelDecode, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedContainers=$acceptedContainers, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, preferredHwDevice=$preferredHwDevice, preset=$preset, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -179,7 +174,6 @@ class SystemConfigFFmpegDto {
json[r'maxBitrate'] = this.maxBitrate;
json[r'preferredHwDevice'] = this.preferredHwDevice;
json[r'preset'] = this.preset;
json[r'realtime'] = this.realtime;
json[r'refs'] = this.refs;
json[r'targetAudioCodec'] = this.targetAudioCodec;
json[r'targetResolution'] = this.targetResolution;
@@ -213,7 +207,6 @@ class SystemConfigFFmpegDto {
maxBitrate: mapValueOfType<String>(json, r'maxBitrate')!,
preferredHwDevice: mapValueOfType<String>(json, r'preferredHwDevice')!,
preset: mapValueOfType<String>(json, r'preset')!,
realtime: SystemConfigFFmpegRealtimeDto.fromJson(json[r'realtime'])!,
refs: mapValueOfType<int>(json, r'refs')!,
targetAudioCodec: AudioCodec.fromJson(json[r'targetAudioCodec'])!,
targetResolution: mapValueOfType<String>(json, r'targetResolution')!,
@@ -282,7 +275,6 @@ class SystemConfigFFmpegDto {
'maxBitrate',
'preferredHwDevice',
'preset',
'realtime',
'refs',
'targetAudioCodec',
'targetResolution',
@@ -1,100 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SystemConfigFFmpegRealtimeDto {
/// Returns a new [SystemConfigFFmpegRealtimeDto] instance.
SystemConfigFFmpegRealtimeDto({
required this.enabled,
});
/// Enable real-time HLS transcoding (alpha)
bool enabled;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegRealtimeDto &&
other.enabled == enabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(enabled.hashCode);
@override
String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'enabled'] = this.enabled;
return json;
}
/// Returns a new [SystemConfigFFmpegRealtimeDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SystemConfigFFmpegRealtimeDto? fromJson(dynamic value) {
upgradeDto(value, "SystemConfigFFmpegRealtimeDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SystemConfigFFmpegRealtimeDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
);
}
return null;
}
static List<SystemConfigFFmpegRealtimeDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigFFmpegRealtimeDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SystemConfigFFmpegRealtimeDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SystemConfigFFmpegRealtimeDto> mapFromJson(dynamic json) {
final map = <String, SystemConfigFFmpegRealtimeDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SystemConfigFFmpegRealtimeDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SystemConfigFFmpegRealtimeDto-objects as value to a dart map
static Map<String, List<SystemConfigFFmpegRealtimeDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SystemConfigFFmpegRealtimeDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SystemConfigFFmpegRealtimeDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'enabled',
};
}
@@ -13,32 +13,26 @@ part of openapi.api;
class SystemConfigNewVersionCheckDto {
/// Returns a new [SystemConfigNewVersionCheckDto] instance.
SystemConfigNewVersionCheckDto({
required this.channel,
required this.enabled,
});
ReleaseChannel channel;
/// Enabled
bool enabled;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigNewVersionCheckDto &&
other.channel == channel &&
other.enabled == enabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(channel.hashCode) +
(enabled.hashCode);
@override
String toString() => 'SystemConfigNewVersionCheckDto[channel=$channel, enabled=$enabled]';
String toString() => 'SystemConfigNewVersionCheckDto[enabled=$enabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'channel'] = this.channel;
json[r'enabled'] = this.enabled;
return json;
}
@@ -52,7 +46,6 @@ class SystemConfigNewVersionCheckDto {
final json = value.cast<String, dynamic>();
return SystemConfigNewVersionCheckDto(
channel: ReleaseChannel.fromJson(json[r'channel'])!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
);
}
@@ -101,7 +94,6 @@ class SystemConfigNewVersionCheckDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'channel',
'enabled',
};
}
-3
View File
@@ -1,9 +1,6 @@
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
class MockSyncApi extends Mock implements SyncApi {}
class MockServerApi extends Mock implements ServerApi {}
class MockPartnerApiRepository extends Mock implements PartnerApiRepository {}
-3
View File
@@ -1,4 +1,3 @@
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
@@ -12,5 +11,3 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockAppSettingsService extends Mock implements AppSettingsService {}
class MockPartnerService extends Mock implements PartnerService {}
@@ -116,7 +116,7 @@ void main() {
when(() => mockApi.serverInfoApi).thenReturn(mockServerApi);
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0, prerelease: null));
).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0));
when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler);
@@ -559,7 +559,7 @@ void main() {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null));
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
await sut.sync();
@@ -587,7 +587,7 @@ void main() {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0, prerelease: null));
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0));
await sut.sync();
verifyInOrder([
@@ -617,7 +617,7 @@ void main() {
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null));
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
await sut.sync();
@@ -0,0 +1,185 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/utils/share_asset.dart';
LocalAsset _local({
String id = 'local-1',
String? remoteId,
AssetType type = AssetType.image,
bool isEdited = false,
String name = 'photo.jpg',
}) {
return LocalAsset(
id: id,
remoteId: remoteId,
name: name,
type: type,
createdAt: DateTime(2025),
updatedAt: DateTime(2025),
playbackStyle: type == AssetType.video ? AssetPlaybackStyle.video : AssetPlaybackStyle.image,
isEdited: isEdited,
);
}
RemoteAsset _remote({
String id = 'remote-1',
String? localId,
AssetType type = AssetType.image,
bool isEdited = false,
String name = 'photo.jpg',
}) {
return RemoteAsset(
id: id,
localId: localId,
name: name,
ownerId: 'owner-1',
checksum: 'checksum-1',
type: type,
createdAt: DateTime(2025),
updatedAt: DateTime(2025),
isEdited: isEdited,
);
}
void main() {
group('canShareAsPreview', () {
test('true for a remote-only image', () {
expect(canShareAsPreview(_remote()), isTrue);
});
test('true for a merged image regardless of which model carries it', () {
expect(canShareAsPreview(_remote(localId: 'local-1')), isTrue);
expect(canShareAsPreview(_local(remoteId: 'remote-1')), isTrue);
});
test('false for a local-only image (no server preview exists)', () {
expect(canShareAsPreview(_local()), isFalse);
});
test('false for videos even when remote (preview is a still JPEG)', () {
expect(canShareAsPreview(_remote(type: AssetType.video)), isFalse);
expect(canShareAsPreview(_remote(localId: 'local-1', type: AssetType.video)), isFalse);
});
});
group('shouldOfferShareQualityChoice', () {
test('false for an empty selection', () {
expect(shouldOfferShareQualityChoice(const []), isFalse);
});
test('false when nothing can provide a preview', () {
expect(shouldOfferShareQualityChoice([_local(), _remote(type: AssetType.video)]), isFalse);
});
test('true when at least one asset can provide a preview', () {
expect(shouldOfferShareQualityChoice([_local(), _remote()]), isTrue);
});
});
group('resolveShareSource - local-only', () {
final asset = _local();
test('original reads the local file', () {
expect(resolveShareSource(asset, ShareAssetQuality.original), const ShareSource.localFile('local-1'));
});
test('preview falls back to the local file (no remote preview available)', () {
expect(resolveShareSource(asset, ShareAssetQuality.preview), const ShareSource.localFile('local-1'));
});
test('edited local-only asset still falls back to the local file', () {
final edited = _local(isEdited: true);
expect(resolveShareSource(edited, ShareAssetQuality.original), const ShareSource.localFile('local-1'));
expect(resolveShareSource(edited, ShareAssetQuality.preview), const ShareSource.localFile('local-1'));
});
});
group('resolveShareSource - remote-only', () {
final asset = _remote();
test('original downloads the original', () {
expect(resolveShareSource(asset, ShareAssetQuality.original), const ShareSource.remoteOriginal('remote-1'));
});
test('preview downloads the preview', () {
expect(resolveShareSource(asset, ShareAssetQuality.preview), const ShareSource.remotePreview('remote-1'));
});
test('edited remote video downloads the original even when preview is requested', () {
final video = _remote(type: AssetType.video, isEdited: true);
expect(resolveShareSource(video, ShareAssetQuality.preview), const ShareSource.remoteOriginal('remote-1'));
});
});
group('resolveShareSource - merged', () {
final mergedFromRemote = _remote(localId: 'local-1');
final mergedFromLocal = _local(remoteId: 'remote-1');
test('original prefers the local file when not edited', () {
expect(resolveShareSource(mergedFromRemote, ShareAssetQuality.original), const ShareSource.localFile('local-1'));
expect(resolveShareSource(mergedFromLocal, ShareAssetQuality.original), const ShareSource.localFile('local-1'));
});
test('preview downloads the preview from the server', () {
expect(resolveShareSource(mergedFromRemote, ShareAssetQuality.preview), const ShareSource.remotePreview('remote-1'));
expect(resolveShareSource(mergedFromLocal, ShareAssetQuality.preview), const ShareSource.remotePreview('remote-1'));
});
test('edited asset downloads the original instead of using the stale local file', () {
final edited = _remote(localId: 'local-1', isEdited: true);
expect(resolveShareSource(edited, ShareAssetQuality.original), const ShareSource.remoteOriginal('remote-1'));
});
test('edited asset can still share the (edited) preview', () {
final edited = _remote(localId: 'local-1', isEdited: true);
expect(resolveShareSource(edited, ShareAssetQuality.preview), const ShareSource.remotePreview('remote-1'));
});
test('video uses the local file even when preview is requested', () {
final video = _remote(localId: 'local-1', type: AssetType.video);
expect(resolveShareSource(video, ShareAssetQuality.preview), const ShareSource.localFile('local-1'));
});
});
group('ShareSource helpers', () {
test('expose the right flags', () {
const local = ShareSource.localFile('a');
const original = ShareSource.remoteOriginal('b');
const preview = ShareSource.remotePreview('c');
expect(local.isLocal, isTrue);
expect(local.requiresDownload, isFalse);
expect(local.isPreview, isFalse);
expect(original.requiresDownload, isTrue);
expect(original.isPreview, isFalse);
expect(preview.requiresDownload, isTrue);
expect(preview.isPreview, isTrue);
});
});
group('shareFilename', () {
test('keeps the original filename for non-preview sources', () {
final asset = _remote(name: 'IMG_0001.HEIC');
expect(shareFilename(asset, const ShareSource.remoteOriginal('remote-1')), 'IMG_0001.HEIC');
expect(shareFilename(asset, const ShareSource.localFile('local-1')), 'IMG_0001.HEIC');
});
test('normalizes the extension to .jpg for preview sources', () {
final raw = _remote(name: 'IMG_0001.CR2');
expect(shareFilename(raw, const ShareSource.remotePreview('remote-1')), 'IMG_0001.jpg');
});
test('appends .jpg when the preview asset has no extension', () {
final asset = _remote(name: 'no_extension');
expect(shareFilename(asset, const ShareSource.remotePreview('remote-1')), 'no_extension.jpg');
});
test('sanitizes path separators in the filename', () {
final asset = _remote(name: 'sub/dir\\file.png');
expect(shareFilename(asset, const ShareSource.remoteOriginal('remote-1')), 'sub_dir_file.png');
expect(shareFilename(asset, const ShareSource.remotePreview('remote-1')), 'sub_dir_file.jpg');
});
});
}
@@ -2,7 +2,6 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
@@ -12,7 +11,6 @@ import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.da
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
@@ -46,10 +44,6 @@ class MockUploadRepository extends Mock implements UploadRepository {}
class MockSyncMigrationRepository extends Mock implements SyncMigrationRepository {}
class MockUserRepository extends Mock implements UserRepository {}
class MockPartnerRepository extends Mock implements PartnerRepository {}
// API Repos
class MockUserApiRepository extends Mock implements UserApiRepository {}
@@ -1,101 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late PartnerRepository sut;
setUp(() {
ctx = MediumRepositoryContext();
sut = PartnerRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('search', () {
test('sharedBy returns users the current user shares their library to', () async {
final me = await ctx.newUser();
final recipient = await ctx.newUser();
final sharer = await ctx.newUser();
await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id);
await ctx.newPartner(sharedById: sharer.id, sharedWithId: me.id);
final result = await sut.search(me.id, PartnerDirection.sharedBy).first;
expect(result.map((partner) => partner.id), unorderedEquals([recipient.id]));
});
test('sharedWith returns users sharing their library with the current user', () async {
final me = await ctx.newUser();
final recipient = await ctx.newUser();
final sharer = await ctx.newUser();
await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id);
await ctx.newPartner(sharedById: sharer.id, sharedWithId: me.id);
final result = await sut.search(me.id, PartnerDirection.sharedWith).first;
expect(result.map((partner) => partner.id), unorderedEquals([sharer.id]));
});
test('emits an updated list when a new partner is added', () async {
final me = await ctx.newUser();
final recipient = await ctx.newUser();
final ids = sut.search(me.id, PartnerDirection.sharedBy).map((partners) => partners.map((p) => p.id).toList());
final expectation = expectLater(
ids,
emitsInOrder([
isEmpty,
unorderedEquals([recipient.id]),
]),
);
await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id);
await expectation;
});
});
group('create', () {
test('inserts a partnership with the current user as the sharer and inTimeline disabled', () async {
final me = await ctx.newUser();
final partner = await ctx.newUser();
await sut.create(partner.id, me.id);
final result = (await sut.search(me.id, .sharedBy).first).first;
expect(result.id, partner.id);
expect(result.inTimeline, isFalse);
});
});
group('update', () {
test('toggles the inTimeline flag for an existing partnership', () async {
final me = await ctx.newUser();
final sharer = await ctx.newUser();
await ctx.newPartner(sharedById: sharer.id, sharedWithId: me.id, inTimeline: false);
await sut.update(sharer.id, me.id, inTimeline: true);
final result = await sut.get(sharer.id, me.id);
expect(result.inTimeline, isTrue);
});
});
group('delete', () {
test('removes the partnership the current user shares by', () async {
final me = await ctx.newUser();
final recipient = await ctx.newUser();
await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id);
await sut.delete(recipient.id, me.id);
final rows = await ctx.db.select(ctx.db.partnerEntity).get();
expect(rows, isEmpty);
});
});
}

Some files were not shown because too many files have changed in this diff Show More