mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b68c442356 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "prettier --cache --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --cache --write --list-different ."
|
"format:fix": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^3.7.4"
|
"prettier": "^3.7.4"
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check for breaking API changes
|
- name: Check for breaking API changes
|
||||||
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
|
# sha is pinning to a commit instead of a tag since the action does not tag versions
|
||||||
|
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
|
||||||
with:
|
with:
|
||||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||||
revision: open-api/immich-openapi-specs.json
|
revision: open-api/immich-openapi-specs.json
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# 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).
|
# 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)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ 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
|
# 📚 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
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ jobs:
|
|||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: pnpm exec playwright install chromium --only-shell
|
run: npx playwright install chromium --only-shell
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Docker build
|
- name: Docker build
|
||||||
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||||
|
|||||||
Vendored
+1
-8
@@ -5,13 +5,6 @@
|
|||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"dart-code.flutter",
|
"dart-code.flutter",
|
||||||
"dart-code.dart-code",
|
"dart-code.dart-code",
|
||||||
"dcmdev.dcm-vscode-extension",
|
"dcmdev.dcm-vscode-extension"
|
||||||
"bradlc.vscode-tailwindcss",
|
|
||||||
"ms-playwright.playwright",
|
|
||||||
"vitest.explorer",
|
|
||||||
"editorconfig.editorconfig",
|
|
||||||
"foxundermoon.shell-format",
|
|
||||||
"timonwong.shellcheck",
|
|
||||||
"bluebrown.yamlfmt"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+13
-35
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"[css]": {
|
"[css]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"[dart]": {
|
"[dart]": {
|
||||||
"editor.defaultFormatter": "Dart-Code.dart-code",
|
"editor.defaultFormatter": "Dart-Code.dart-code",
|
||||||
@@ -18,15 +19,18 @@
|
|||||||
"source.removeUnusedImports": "explicit"
|
"source.removeUnusedImports": "explicit"
|
||||||
},
|
},
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"[jsonc]": {
|
"[jsonc]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"[svelte]": {
|
"[svelte]": {
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
@@ -34,7 +38,8 @@
|
|||||||
"source.removeUnusedImports": "explicit"
|
"source.removeUnusedImports": "explicit"
|
||||||
},
|
},
|
||||||
"editor.defaultFormatter": "svelte.svelte-vscode",
|
"editor.defaultFormatter": "svelte.svelte-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
@@ -42,45 +47,18 @@
|
|||||||
"source.removeUnusedImports": "explicit"
|
"source.removeUnusedImports": "explicit"
|
||||||
},
|
},
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"cSpell.words": ["immich"],
|
"cSpell.words": ["immich"],
|
||||||
"css.lint.unknownAtRules": "ignore",
|
|
||||||
"editor.bracketPairColorization.enabled": true,
|
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"eslint.useFlatConfig": true,
|
|
||||||
"eslint.validate": ["javascript", "typescript", "svelte"],
|
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||||
"eslint.workingDirectories": [
|
|
||||||
{ "directory": "cli", "changeProcessCWD": true },
|
|
||||||
{ "directory": "e2e", "changeProcessCWD": true },
|
|
||||||
{ "directory": "server", "changeProcessCWD": true },
|
|
||||||
{ "directory": "web", "changeProcessCWD": true }
|
|
||||||
],
|
|
||||||
"files.watcherExclude": {
|
|
||||||
"**/.jj/**": true,
|
|
||||||
"**/.git/**": true,
|
|
||||||
"**/node_modules/**": true,
|
|
||||||
"**/build/**": true,
|
|
||||||
"**/dist/**": true,
|
|
||||||
"**/.svelte-kit/**": true
|
|
||||||
},
|
|
||||||
"explorer.fileNesting.enabled": true,
|
"explorer.fileNesting.enabled": true,
|
||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||||
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
||||||
},
|
},
|
||||||
"search.exclude": {
|
|
||||||
"**/node_modules": true,
|
|
||||||
"**/build": true,
|
|
||||||
"**/dist": true,
|
|
||||||
"**/.svelte-kit": true,
|
|
||||||
"**/open-api/typescript-sdk/src": true
|
|
||||||
},
|
|
||||||
"svelte.enable-ts-plugin": true,
|
"svelte.enable-ts-plugin": true,
|
||||||
"tailwindCSS.experimental.configFile": {
|
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||||
"web/src/app.css": "web/src/**"
|
|
||||||
},
|
|
||||||
"js/ts.preferences.importModuleSpecifier": "non-relative",
|
|
||||||
"vitest.maximumConfigs": 10
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ Please try to keep pull requests as focused as possible. A PR should do exactly
|
|||||||
|
|
||||||
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
|
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
|
||||||
|
|
||||||
We usually do not assign issues to new contributors, since it happens often that a PR is never even opened. Again, reach out on Discord if you fear putting a lot of time into fixing an issue, but ending up with a duplicate PR.
|
|
||||||
|
|
||||||
## Use of generative AI
|
## Use of generative AI
|
||||||
|
|
||||||
We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request.
|
We ask you not to open PRs generated with an LLM. We find that code generated like this tends to need a large amount of back-and-forth, which is a very inefficient use of our time. If we want LLM-generated code, it's much faster for us to use an LLM ourselves than to go through an intermediary via a pull request.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ attach-server:
|
|||||||
docker exec -it docker_immich-server_1 sh
|
docker exec -it docker_immich-server_1 sh
|
||||||
|
|
||||||
renovate:
|
renovate:
|
||||||
LOG_LEVEL=debug pnpm exec renovate --platform=local --repository-cache=reset
|
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
|
||||||
|
|
||||||
# Directories that need to be created for volumes or build output
|
# Directories that need to be created for volumes or build output
|
||||||
VOLUME_DIRS = \
|
VOLUME_DIRS = \
|
||||||
|
|||||||
+11
-11
@@ -13,23 +13,23 @@
|
|||||||
"cli"
|
"cli"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.0",
|
"@eslint/js": "^9.8.0",
|
||||||
"@immich/sdk": "workspace:*",
|
"@immich/sdk": "workspace:*",
|
||||||
"@types/byte-size": "^8.1.0",
|
"@types/byte-size": "^8.1.0",
|
||||||
"@types/cli-progress": "^3.11.0",
|
"@types/cli-progress": "^3.11.0",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^24.10.14",
|
"@types/node": "^24.10.13",
|
||||||
"@vitest/coverage-v8": "^4.0.0",
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"byte-size": "^9.0.0",
|
"byte-size": "^9.0.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"eslint": "^10.0.0",
|
"eslint": "^9.14.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-unicorn": "^63.0.0",
|
"eslint-plugin-unicorn": "^62.0.0",
|
||||||
"globals": "^17.0.0",
|
"globals": "^16.0.0",
|
||||||
"mock-fs": "^5.2.0",
|
"mock-fs": "^5.2.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-organize-imports": "^4.0.0",
|
"prettier-plugin-organize-imports": "^4.0.0",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
"vite-tsconfig-paths": "^6.0.0",
|
"vite-tsconfig-paths": "^6.0.0",
|
||||||
"vitest": "^4.0.0",
|
"vitest": "^3.0.0",
|
||||||
"vitest-fetch-mock": "^0.4.0",
|
"vitest-fetch-mock": "^0.4.0",
|
||||||
"yaml": "^2.3.1"
|
"yaml": "^2.3.1"
|
||||||
},
|
},
|
||||||
@@ -45,12 +45,12 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:dev": "vite build --sourcemap true",
|
"build:dev": "vite build --sourcemap true",
|
||||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||||
"lint:fix": "pnpm run lint --fix",
|
"lint:fix": "npm run lint -- --fix",
|
||||||
"prepack": "pnpm run build",
|
"prepack": "npm run build",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:cov": "vitest --coverage",
|
"test:cov": "vitest --coverage",
|
||||||
"format": "prettier --cache --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --cache --write --list-different .",
|
"format:fix": "prettier --write .",
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { setTimeout as sleep } from 'node:timers/promises';
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
import { describe, expect, it, MockedFunction, vi } from 'vitest';
|
import { describe, expect, it, MockedFunction, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ describe('uploadFiles', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns new assets when upload file is successful', async () => {
|
it('returns new assets when upload file is successful', async () => {
|
||||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }),
|
body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }),
|
||||||
@@ -75,7 +75,7 @@ describe('uploadFiles', () => {
|
|||||||
|
|
||||||
it('returns new assets when upload file retry is successful', async () => {
|
it('returns new assets when upload file retry is successful', async () => {
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
||||||
counter++;
|
counter++;
|
||||||
if (counter < retry) {
|
if (counter < retry) {
|
||||||
throw new Error('Network error');
|
throw new Error('Network error');
|
||||||
@@ -96,7 +96,7 @@ describe('uploadFiles', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns new assets when upload file retry is failed', async () => {
|
it('returns new assets when upload file retry is failed', async () => {
|
||||||
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
|
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), () => {
|
||||||
throw new Error('Network error');
|
throw new Error('Network error');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,19 +236,16 @@ describe('startWatch', () => {
|
|||||||
await sleep(100); // to debounce the watcher from considering the test file as a existing file
|
await sleep(100); // to debounce the watcher from considering the test file as a existing file
|
||||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||||
|
|
||||||
await vi.waitFor(
|
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||||
() =>
|
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
assetBulkUploadCheckDto: {
|
||||||
assetBulkUploadCheckDto: {
|
assets: [
|
||||||
assets: [
|
expect.objectContaining({
|
||||||
expect.objectContaining({
|
id: testFilePath,
|
||||||
id: testFilePath,
|
}),
|
||||||
}),
|
],
|
||||||
],
|
},
|
||||||
},
|
});
|
||||||
}),
|
|
||||||
{ timeout: 5000 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter out unsupported files', async () => {
|
it('should filter out unsupported files', async () => {
|
||||||
@@ -260,19 +257,16 @@ describe('startWatch', () => {
|
|||||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||||
await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
|
await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
|
||||||
|
|
||||||
await vi.waitFor(
|
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||||
() =>
|
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
assetBulkUploadCheckDto: {
|
||||||
assetBulkUploadCheckDto: {
|
assets: expect.arrayContaining([
|
||||||
assets: expect.arrayContaining([
|
expect.objectContaining({
|
||||||
expect.objectContaining({
|
id: testFilePath,
|
||||||
id: testFilePath,
|
}),
|
||||||
}),
|
]),
|
||||||
]),
|
},
|
||||||
},
|
});
|
||||||
}),
|
|
||||||
{ timeout: 5000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
||||||
assetBulkUploadCheckDto: {
|
assetBulkUploadCheckDto: {
|
||||||
@@ -297,19 +291,16 @@ describe('startWatch', () => {
|
|||||||
await fs.promises.writeFile(testFilePath, 'testjpg');
|
await fs.promises.writeFile(testFilePath, 'testjpg');
|
||||||
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
|
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
|
||||||
|
|
||||||
await vi.waitFor(
|
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
|
||||||
() =>
|
expect(checkBulkUpload).toHaveBeenCalledWith({
|
||||||
expect(checkBulkUpload).toHaveBeenCalledWith({
|
assetBulkUploadCheckDto: {
|
||||||
assetBulkUploadCheckDto: {
|
assets: expect.arrayContaining([
|
||||||
assets: expect.arrayContaining([
|
expect.objectContaining({
|
||||||
expect.objectContaining({
|
id: testFilePath,
|
||||||
id: testFilePath,
|
}),
|
||||||
}),
|
]),
|
||||||
]),
|
},
|
||||||
},
|
});
|
||||||
}),
|
|
||||||
{ timeout: 5000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
expect(checkBulkUpload).not.toHaveBeenCalledWith({
|
||||||
assetBulkUploadCheckDto: {
|
assetBulkUploadCheckDto: {
|
||||||
|
|||||||
+2
-6
@@ -1,4 +1,4 @@
|
|||||||
import { defineConfig, UserConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -17,8 +17,4 @@ export default defineConfig({
|
|||||||
noExternal: /^(?!node:).*$/,
|
noExternal: /^(?!node:).*$/,
|
||||||
},
|
},
|
||||||
plugins: [tsconfigPaths()],
|
plugins: [tsconfigPaths()],
|
||||||
test: {
|
});
|
||||||
name: 'cli:unit',
|
|
||||||
globals: true,
|
|
||||||
},
|
|
||||||
} as UserConfig);
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[tools]
|
[tools]
|
||||||
terragrunt = "0.99.4"
|
terragrunt = "0.98.0"
|
||||||
opentofu = "1.11.5"
|
opentofu = "1.11.4"
|
||||||
|
|
||||||
[tasks."tg:fmt"]
|
[tasks."tg:fmt"]
|
||||||
run = "terragrunt hclfmt"
|
run = "terragrunt hclfmt"
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@@ -85,7 +85,7 @@ services:
|
|||||||
container_name: immich_prometheus
|
container_name: immich_prometheus
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- 9090:9090
|
||||||
image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
|
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||||
user: '1000:1000'
|
user: '1000:1000'
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -67,8 +67,7 @@ graph TD
|
|||||||
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
||||||
D --> E[Smart Search]
|
D --> E[Smart Search]
|
||||||
D --> F[Face Detection]
|
D --> F[Face Detection]
|
||||||
D --> G[OCR]
|
D --> G[Video Transcoding]
|
||||||
D --> H[Video Transcoding]
|
E --> H[Duplicate Detection]
|
||||||
E --> I[Duplicate Detection]
|
F --> I[Facial Recognition]
|
||||||
F --> J[Facial Recognition]
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Immich has three main clients:
|
|||||||
3. CLI - Command-line utility for bulk upload
|
3. CLI - Command-line utility for bulk upload
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
All three clients use [OpenAPI](/api.md) to auto-generate rest clients for easy integration. For more information about this process, see [OpenAPI](/api.md).
|
All three clients use [OpenAPI](./open-api.md) to auto-generate rest clients for easy integration. For more information about this process, see [OpenAPI](./open-api.md).
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### Mobile App
|
### Mobile App
|
||||||
@@ -71,7 +71,7 @@ An incoming HTTP request is mapped to a controller (`src/controllers`). Controll
|
|||||||
|
|
||||||
### Domain Transfer Objects (DTOs)
|
### Domain Transfer Objects (DTOs)
|
||||||
|
|
||||||
The server uses [Domain Transfer Objects](https://en.wikipedia.org/wiki/Data_transfer_object) as public interfaces for the inputs (query, params, and body) and outputs (response) for each endpoint. DTOs translate to [OpenAPI](/api.md) schemas and control the generated code used by each client.
|
The server uses [Domain Transfer Objects](https://en.wikipedia.org/wiki/Data_transfer_object) as public interfaces for the inputs (query, params, and body) and outputs (response) for each endpoint. DTOs translate to [OpenAPI](./open-api.md) schemas and control the generated code used by each client.
|
||||||
|
|
||||||
### Background Jobs
|
### Background Jobs
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# API
|
# OpenAPI
|
||||||
|
|
||||||
Immich uses the [OpenAPI](https://swagger.io/specification/) standard to generate API documentation. To view the published docs see [here](https://api.immich.app/).
|
Immich uses the [OpenAPI](https://swagger.io/specification/) standard to generate API documentation. To view the published docs see [here](https://api.immich.app/).
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ You can use `dart fix --apply` and `dcm fix lib` to potentially correct some iss
|
|||||||
|
|
||||||
## OpenAPI
|
## OpenAPI
|
||||||
|
|
||||||
The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/api.md) for more details.
|
The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/developer/open-api.md) for more details.
|
||||||
|
|
||||||
## Database Migrations
|
## Database Migrations
|
||||||
|
|
||||||
|
|||||||
@@ -80,10 +80,6 @@ There is an automatic scan job that is scheduled to run once a day. Its schedule
|
|||||||
|
|
||||||
This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library management page.
|
This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library management page.
|
||||||
|
|
||||||
### Deleting a Library
|
|
||||||
|
|
||||||
When deleting an external library, all assets inside are immediately deleted along with the library. Note that while a library can take a long time to fully delete in the background, it is immediately removed from the library list. If the deletion process is interrupted (for example, due to server restart), it will be cleaned up in the next nightly cron job. The cleanup process can also be manually initiated by clicking the "Scan All Libraries" button in the library list.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Let's show a concrete example where we add an existing gallery to Immich. Here, we have the following folders we want to add:
|
Let's show a concrete example where we add an existing gallery to Immich. Here, we have the following folders we want to add:
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ You do not need to redo any machine learning jobs after enabling hardware accele
|
|||||||
- The GPU must be supported by ROCm. If it isn't officially supported, you can attempt to use the `HSA_OVERRIDE_GFX_VERSION` environmental variable: `HSA_OVERRIDE_GFX_VERSION=<a supported version, e.g. 10.3.0>`. If this doesn't work, you might need to also set `HSA_USE_SVM=0`.
|
- The GPU must be supported by ROCm. If it isn't officially supported, you can attempt to use the `HSA_OVERRIDE_GFX_VERSION` environmental variable: `HSA_OVERRIDE_GFX_VERSION=<a supported version, e.g. 10.3.0>`. If this doesn't work, you might need to also set `HSA_USE_SVM=0`.
|
||||||
- The ROCm image is quite large and requires at least 35GiB of free disk space. However, pulling later updates to the service through Docker will generally only amount to a few hundred megabytes as the rest will be cached.
|
- The ROCm image is quite large and requires at least 35GiB of free disk space. However, pulling later updates to the service through Docker will generally only amount to a few hundred megabytes as the rest will be cached.
|
||||||
- This backend is new and may experience some issues. For example, GPU power consumption can be higher than usual after running inference, even if the machine learning service is idle. In this case, it will only go back to normal after being idle for 5 minutes (configurable with the [MACHINE_LEARNING_MODEL_TTL](/install/environment-variables) setting).
|
- This backend is new and may experience some issues. For example, GPU power consumption can be higher than usual after running inference, even if the machine learning service is idle. In this case, it will only go back to normal after being idle for 5 minutes (configurable with the [MACHINE_LEARNING_MODEL_TTL](/install/environment-variables) setting).
|
||||||
- MIGraphX is a new backend for AMD cards, which compiles models at runtime. As such, the first few inferences will be slow.
|
|
||||||
|
|
||||||
#### OpenVINO
|
#### OpenVINO
|
||||||
|
|
||||||
|
|||||||
@@ -166,8 +166,6 @@ Redis (Sentinel) URL example JSON before encoding:
|
|||||||
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
|
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
|
||||||
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
|
|
||||||
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
|
|
||||||
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
|
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
|
||||||
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
|
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
|
||||||
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
||||||
|
|||||||
+48
-70
@@ -6,7 +6,7 @@ const prism = require('prism-react-renderer');
|
|||||||
/** @type {import('@docusaurus/types').Config} */
|
/** @type {import('@docusaurus/types').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
title: 'Immich',
|
title: 'Immich',
|
||||||
tagline: 'Self-hosted photo and video management solution',
|
tagline: 'High performance self-hosted photo and video backup solution directly from your mobile phone',
|
||||||
url: 'https://docs.immich.app',
|
url: 'https://docs.immich.app',
|
||||||
baseUrl: '/',
|
baseUrl: '/',
|
||||||
onBrokenLinks: 'throw',
|
onBrokenLinks: 'throw',
|
||||||
@@ -93,15 +93,35 @@ const config = {
|
|||||||
position: 'right',
|
position: 'right',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: 'https://immich.app/',
|
to: '/overview/quick-start',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
label: 'Home',
|
label: 'Docs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://immich.app/roadmap',
|
||||||
|
position: 'right',
|
||||||
|
label: 'Roadmap',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://api.immich.app/',
|
||||||
|
position: 'right',
|
||||||
|
label: 'API',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://immich.store',
|
||||||
|
position: 'right',
|
||||||
|
label: 'Merch',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: 'https://github.com/immich-app/immich',
|
href: 'https://github.com/immich-app/immich',
|
||||||
label: 'GitHub',
|
label: 'GitHub',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: 'https://discord.immich.app',
|
||||||
|
label: 'Discord',
|
||||||
|
position: 'right',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'html',
|
type: 'html',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
@@ -114,78 +134,19 @@ const config = {
|
|||||||
style: 'light',
|
style: 'light',
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
title: 'Download',
|
title: 'Overview',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Android',
|
label: 'Quick start',
|
||||||
href: 'https://get.immich.app/android',
|
to: '/overview/quick-start',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'iOS',
|
label: 'Installation',
|
||||||
href: 'https://get.immich.app/ios',
|
to: '/install/requirements',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Server',
|
label: 'Contributing',
|
||||||
href: 'https://immich.app/download',
|
to: '/overview/support-the-project',
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Company',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: 'FUTO',
|
|
||||||
href: 'https://futo.tech/',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Purchase',
|
|
||||||
href: 'https://buy.immich.app/',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Merch',
|
|
||||||
href: 'https://immich.store/',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Sites',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: 'Home',
|
|
||||||
href: 'https://immich.app',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'My Immich',
|
|
||||||
href: 'https://my.immich.app/',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Awesome Immich',
|
|
||||||
href: 'https://awesome.immich.app/',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Immich API',
|
|
||||||
href: 'https://api.immich.app/',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Immich Data',
|
|
||||||
href: 'https://data.immich.app/',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Immich Datasets',
|
|
||||||
href: 'https://datasets.immich.app/',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Miscellaneous',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: 'Roadmap',
|
|
||||||
href: 'https://immich.app/roadmap',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Cursed Knowledge',
|
|
||||||
href: 'https://immich.app/cursed-knowledge',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Privacy Policy',
|
label: 'Privacy Policy',
|
||||||
@@ -194,7 +155,24 @@ const config = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Social',
|
title: 'Documentation',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Roadmap',
|
||||||
|
href: 'https://immich.app/roadmap',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'API',
|
||||||
|
href: 'https://api.immich.app/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cursed Knowledge',
|
||||||
|
href: 'https://immich.app/cursed-knowledge',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Links',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'GitHub',
|
label: 'GitHub',
|
||||||
|
|||||||
+3
-3
@@ -4,11 +4,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"docusaurus": "docusaurus",
|
"docusaurus": "docusaurus",
|
||||||
"format": "prettier --cache --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --cache --write --list-different .",
|
"format:fix": "prettier --write .",
|
||||||
"start": "docusaurus start --port 3005",
|
"start": "docusaurus start --port 3005",
|
||||||
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
|
||||||
"build": "pnpm run copy:openapi && docusaurus build",
|
"build": "npm run copy:openapi && docusaurus build",
|
||||||
"swizzle": "docusaurus swizzle",
|
"swizzle": "docusaurus swizzle",
|
||||||
"deploy": "docusaurus deploy",
|
"deploy": "docusaurus deploy",
|
||||||
"clear": "docusaurus clear",
|
"clear": "docusaurus clear",
|
||||||
|
|||||||
Vendored
-1
@@ -23,7 +23,6 @@
|
|||||||
/features/storage-template /administration/storage-template 307
|
/features/storage-template /administration/storage-template 307
|
||||||
/features/user-management /administration/user-management 307
|
/features/user-management /administration/user-management 307
|
||||||
/developer/contributing /developer/pr-checklist 307
|
/developer/contributing /developer/pr-checklist 307
|
||||||
/developer/open-api /api 307
|
|
||||||
/guides/machine-learning /guides/remote-machine-learning 307
|
/guides/machine-learning /guides/remote-machine-learning 307
|
||||||
/administration/password-login /administration/system-settings 307
|
/administration/password-login /administration/system-settings 307
|
||||||
/features/search /features/searching 307
|
/features/search /features/searching 307
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich-e2e-redis
|
container_name: immich-e2e-redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
+17
-18
@@ -8,41 +8,41 @@
|
|||||||
"test": "vitest --run",
|
"test": "vitest --run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:maintenance": "vitest --run --config vitest.maintenance.config.ts",
|
"test:maintenance": "vitest --run --config vitest.maintenance.config.ts",
|
||||||
"test:web": "pnpm exec playwright test --project=web",
|
"test:web": "npx playwright test --project=web",
|
||||||
"test:web:maintenance": "pnpm exec playwright test --project=maintenance",
|
"test:web:maintenance": "npx playwright test --project=maintenance",
|
||||||
"test:web:ui": "pnpm exec playwright test --project=ui",
|
"test:web:ui": "npx playwright test --project=ui",
|
||||||
"start:web": "pnpm exec playwright test --ui --project=web",
|
"start:web": "npx playwright test --ui --project=web",
|
||||||
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
|
"start:web:maintenance": "npx playwright test --ui --project=maintenance",
|
||||||
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
|
"start:web:ui": "npx playwright test --ui --project=ui",
|
||||||
"format": "prettier --cache --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --cache --write --list-different .",
|
"format:fix": "prettier --write .",
|
||||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||||
"lint:fix": "pnpm run lint --fix",
|
"lint:fix": "npm run lint -- --fix",
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.0",
|
"@eslint/js": "^9.8.0",
|
||||||
"@faker-js/faker": "^10.1.0",
|
"@faker-js/faker": "^10.1.0",
|
||||||
"@immich/cli": "workspace:*",
|
"@immich/cli": "workspace:*",
|
||||||
"@immich/e2e-auth-server": "workspace:*",
|
"@immich/e2e-auth-server": "workspace:*",
|
||||||
"@immich/sdk": "workspace:*",
|
"@immich/sdk": "workspace:*",
|
||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@socket.io/component-emitter": "^3.1.2",
|
"@socket.io/component-emitter": "^3.1.2",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^24.10.14",
|
"@types/node": "^24.10.13",
|
||||||
"@types/pg": "^8.15.1",
|
"@types/pg": "^8.15.1",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^10.0.0",
|
"eslint": "^9.14.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-unicorn": "^63.0.0",
|
"eslint-plugin-unicorn": "^62.0.0",
|
||||||
"exiftool-vendored": "^35.0.0",
|
"exiftool-vendored": "^34.3.0",
|
||||||
"globals": "^17.0.0",
|
"globals": "^16.0.0",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
@@ -54,8 +54,7 @@
|
|||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"utimes": "^5.2.1",
|
"utimes": "^5.2.1",
|
||||||
"vite-tsconfig-paths": "^6.1.1",
|
"vitest": "^3.0.0"
|
||||||
"vitest": "^4.0.0"
|
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.13.1"
|
"node": "24.13.1"
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||||
|
import { expect, Page, test } from '@playwright/test';
|
||||||
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
|
async function ensureDetailPanelVisible(page: Page) {
|
||||||
|
await page.waitForSelector('#immich-asset-viewer');
|
||||||
|
|
||||||
|
const isVisible = await page.locator('#detail-panel').isVisible();
|
||||||
|
if (!isVisible) {
|
||||||
|
await page.keyboard.press('i');
|
||||||
|
await page.waitForSelector('#detail-panel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Asset Viewer stack', () => {
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
let assetOne: AssetMediaResponseDto;
|
||||||
|
let assetTwo: AssetMediaResponseDto;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
utils.initSdk();
|
||||||
|
await utils.resetDatabase();
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } });
|
||||||
|
|
||||||
|
assetOne = await utils.createAsset(admin.accessToken);
|
||||||
|
assetTwo = await utils.createAsset(admin.accessToken);
|
||||||
|
await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]);
|
||||||
|
|
||||||
|
const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']);
|
||||||
|
const tagOne = tags.find((tag) => tag.value === 'test/1')!;
|
||||||
|
const tagTwo = tags.find((tag) => tag.value === 'test/2')!;
|
||||||
|
await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]);
|
||||||
|
await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stack slideshow is visible', async ({ page, context }) => {
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
await page.goto(`/photos/${assetOne.id}`);
|
||||||
|
|
||||||
|
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||||
|
await expect(stackAssets.first()).toBeVisible();
|
||||||
|
await expect(stackAssets.nth(1)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tags of primary asset are visible', async ({ page, context }) => {
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
await page.goto(`/photos/${assetOne.id}`);
|
||||||
|
await ensureDetailPanelVisible(page);
|
||||||
|
|
||||||
|
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||||
|
await expect(tags.first()).toHaveText('test/1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tags of second asset are visible', async ({ page, context }) => {
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
await page.goto(`/photos/${assetOne.id}`);
|
||||||
|
await ensureDetailPanelVisible(page);
|
||||||
|
|
||||||
|
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||||
|
await stackAssets.nth(1).click();
|
||||||
|
|
||||||
|
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||||
|
await expect(tags.first()).toHaveText('test/2');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,7 +45,8 @@ test.describe('Shared Links', () => {
|
|||||||
await page.goto(`/share/${sharedLink.key}`);
|
await page.goto(`/share/${sharedLink.key}`);
|
||||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||||
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
|
await page.locator(`[data-asset-id="${asset.id}"]`).hover();
|
||||||
await page.waitForSelector(`[data-asset-id="${asset.id}"] [role="checkbox"]`);
|
await page.waitForSelector('[data-group] svg');
|
||||||
|
await page.getByRole('checkbox').click();
|
||||||
await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
|
await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Download' }).click()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
import { faker } from '@faker-js/faker';
|
|
||||||
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
|
|
||||||
import { BrowserContext } from '@playwright/test';
|
|
||||||
import { randomPreview, randomThumbnail } from 'src/ui/generators/timeline';
|
|
||||||
|
|
||||||
export type MockStack = {
|
|
||||||
id: string;
|
|
||||||
primaryAssetId: string;
|
|
||||||
assets: AssetResponseDto[];
|
|
||||||
brokenAssetIds: Set<string>;
|
|
||||||
assetMap: Map<string, AssetResponseDto>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
|
|
||||||
const assetId = faker.string.uuid();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
return {
|
|
||||||
id: assetId,
|
|
||||||
deviceAssetId: `device-${assetId}`,
|
|
||||||
ownerId,
|
|
||||||
owner: {
|
|
||||||
id: ownerId,
|
|
||||||
email: 'admin@immich.cloud',
|
|
||||||
name: 'Admin',
|
|
||||||
profileImagePath: '',
|
|
||||||
profileChangedAt: now,
|
|
||||||
avatarColor: 'blue' as never,
|
|
||||||
},
|
|
||||||
libraryId: `library-${ownerId}`,
|
|
||||||
deviceId: `device-${ownerId}`,
|
|
||||||
type: AssetTypeEnum.Image,
|
|
||||||
originalPath: `/original/${assetId}.jpg`,
|
|
||||||
originalFileName: `${assetId}.jpg`,
|
|
||||||
originalMimeType: 'image/jpeg',
|
|
||||||
thumbhash: null,
|
|
||||||
fileCreatedAt: now,
|
|
||||||
fileModifiedAt: now,
|
|
||||||
localDateTime: now,
|
|
||||||
updatedAt: now,
|
|
||||||
createdAt: now,
|
|
||||||
isFavorite: false,
|
|
||||||
isArchived: false,
|
|
||||||
isTrashed: false,
|
|
||||||
visibility: AssetVisibility.Timeline,
|
|
||||||
duration: '0:00:00.00000',
|
|
||||||
exifInfo: {
|
|
||||||
make: null,
|
|
||||||
model: null,
|
|
||||||
exifImageWidth: 3000,
|
|
||||||
exifImageHeight: 4000,
|
|
||||||
fileSizeInByte: null,
|
|
||||||
orientation: null,
|
|
||||||
dateTimeOriginal: now,
|
|
||||||
modifyDate: null,
|
|
||||||
timeZone: null,
|
|
||||||
lensModel: null,
|
|
||||||
fNumber: null,
|
|
||||||
focalLength: null,
|
|
||||||
iso: null,
|
|
||||||
exposureTime: null,
|
|
||||||
latitude: null,
|
|
||||||
longitude: null,
|
|
||||||
city: null,
|
|
||||||
country: null,
|
|
||||||
state: null,
|
|
||||||
description: null,
|
|
||||||
},
|
|
||||||
livePhotoVideoId: null,
|
|
||||||
tags: [],
|
|
||||||
people: [],
|
|
||||||
unassignedFaces: [],
|
|
||||||
stack: null,
|
|
||||||
isOffline: false,
|
|
||||||
hasMetadata: true,
|
|
||||||
duplicateId: null,
|
|
||||||
resized: true,
|
|
||||||
checksum: faker.string.alphanumeric({ length: 28 }),
|
|
||||||
width: 3000,
|
|
||||||
height: 4000,
|
|
||||||
isEdited: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createMockStack = (
|
|
||||||
primaryAssetDto: AssetResponseDto,
|
|
||||||
additionalAssets: AssetResponseDto[],
|
|
||||||
brokenAssetIds?: Set<string>,
|
|
||||||
): MockStack => {
|
|
||||||
const stackId = faker.string.uuid();
|
|
||||||
const allAssets = [primaryAssetDto, ...additionalAssets];
|
|
||||||
const resolvedBrokenIds = brokenAssetIds ?? new Set(additionalAssets.map((a) => a.id));
|
|
||||||
const assetMap = new Map(allAssets.map((a) => [a.id, a]));
|
|
||||||
|
|
||||||
primaryAssetDto.stack = {
|
|
||||||
id: stackId,
|
|
||||||
assetCount: allAssets.length,
|
|
||||||
primaryAssetId: primaryAssetDto.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: stackId,
|
|
||||||
primaryAssetId: primaryAssetDto.id,
|
|
||||||
assets: allAssets,
|
|
||||||
brokenAssetIds: resolvedBrokenIds,
|
|
||||||
assetMap,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setupBrokenAssetMockApiRoutes = async (context: BrowserContext, mockStack: MockStack) => {
|
|
||||||
await context.route('**/api/stacks/*', async (route, request) => {
|
|
||||||
if (request.method() !== 'GET') {
|
|
||||||
return route.fallback();
|
|
||||||
}
|
|
||||||
const stackResponse: StackResponseDto = {
|
|
||||||
id: mockStack.id,
|
|
||||||
primaryAssetId: mockStack.primaryAssetId,
|
|
||||||
assets: mockStack.assets,
|
|
||||||
};
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
json: stackResponse,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await context.route('**/api/assets/*', async (route, request) => {
|
|
||||||
if (request.method() !== 'GET') {
|
|
||||||
return route.fallback();
|
|
||||||
}
|
|
||||||
const url = new URL(request.url());
|
|
||||||
const segments = url.pathname.split('/');
|
|
||||||
const assetId = segments.at(-1);
|
|
||||||
if (assetId && mockStack.assetMap.has(assetId)) {
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
json: mockStack.assetMap.get(assetId),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return route.fallback();
|
|
||||||
});
|
|
||||||
|
|
||||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
|
||||||
if (!route.request().serviceWorker()) {
|
|
||||||
return route.continue();
|
|
||||||
}
|
|
||||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
|
||||||
const match = request.url().match(pattern);
|
|
||||||
if (!match?.groups || !mockStack.assetMap.has(match.groups.assetId)) {
|
|
||||||
return route.fallback();
|
|
||||||
}
|
|
||||||
if (mockStack.brokenAssetIds.has(match.groups.assetId)) {
|
|
||||||
return route.fulfill({ status: 404 });
|
|
||||||
}
|
|
||||||
const asset = mockStack.assetMap.get(match.groups.assetId)!;
|
|
||||||
const ratio = (asset.exifInfo?.exifImageWidth ?? 3000) / (asset.exifInfo?.exifImageHeight ?? 4000);
|
|
||||||
const body =
|
|
||||||
match.groups.size === 'preview'
|
|
||||||
? await randomPreview(match.groups.assetId, ratio)
|
|
||||||
: await randomThumbnail(match.groups.assetId, ratio);
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'image/jpeg' },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { BrowserContext } from '@playwright/test';
|
|
||||||
import { randomThumbnail } from 'src/ui/generators/timeline';
|
|
||||||
|
|
||||||
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
|
|
||||||
const MINIMAL_MP4_BASE64 =
|
|
||||||
'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAr9tZGF0AAACoAYF//+c' +
|
|
||||||
'3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDEyNSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMg' +
|
|
||||||
'LSBDb3B5bGVmdCAyMDAzLTIwMTIgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwg' +
|
|
||||||
'LSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMg' +
|
|
||||||
'bWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5n' +
|
|
||||||
'ZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEg' +
|
|
||||||
'ZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJl' +
|
|
||||||
'YWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJh' +
|
|
||||||
'eV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2Fk' +
|
|
||||||
'YXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtl' +
|
|
||||||
'eWludD0yNTAga2V5aW50X21pbj0yNCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9v' +
|
|
||||||
'a2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBt' +
|
|
||||||
'YXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAA9liIQAV/0TAAYdeBTX' +
|
|
||||||
'zg8AAALvbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAACoAAQAAAQAAAAAAAAAAAAAAAAEAAAAA' +
|
|
||||||
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA' +
|
|
||||||
'Ahl0cmFrAAAAXHRraGQAAAAPAAAAAAAAAAAAAAABAAAAAAAAACoAAAAAAAAAAAAAAAAAAAAAAAEAAAAA' +
|
|
||||||
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAgAAAAIAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAA' +
|
|
||||||
'AAEAAAAqAAAAAAABAAAAAAGRbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAAAgBVxAAAAAAA' +
|
|
||||||
'LWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABPG1pbmYAAAAUdm1oZAAA' +
|
|
||||||
'AAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAPxzdGJsAAAAmHN0' +
|
|
||||||
'c2QAAAAAAAAAAQAAAIhhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAgACABIAAAASAAAAAAAAAAB' +
|
|
||||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBZAAK/+EAGWdkAAqs' +
|
|
||||||
'2V+WXAWyAAADAAIAAAMAYB4kSywBAAZo6+PLIsAAAAAYc3R0cwAAAAAAAAABAAAAAQAAAgAAAAAcc3Rz' +
|
|
||||||
'YwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACtwAAAAEAAAAUc3RjbwAAAAAAAAABAAAA' +
|
|
||||||
'MAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWls' +
|
|
||||||
'c3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNTQuNjMuMTA0';
|
|
||||||
|
|
||||||
export const MINIMAL_MP4_BUFFER = Buffer.from(MINIMAL_MP4_BASE64, 'base64');
|
|
||||||
|
|
||||||
export type MockPerson = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
birthDate: string | null;
|
|
||||||
isHidden: boolean;
|
|
||||||
thumbnailPath: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createMockPeople = (count: number): MockPerson[] => {
|
|
||||||
const names = [
|
|
||||||
'Alice Johnson',
|
|
||||||
'Bob Smith',
|
|
||||||
'Charlie Brown',
|
|
||||||
'Diana Prince',
|
|
||||||
'Eve Adams',
|
|
||||||
'Frank Castle',
|
|
||||||
'Grace Lee',
|
|
||||||
'Hank Pym',
|
|
||||||
'Iris West',
|
|
||||||
'Jack Ryan',
|
|
||||||
];
|
|
||||||
return Array.from({ length: count }, (_, index) => ({
|
|
||||||
id: `person-${index}`,
|
|
||||||
name: names[index % names.length],
|
|
||||||
birthDate: null,
|
|
||||||
isHidden: false,
|
|
||||||
thumbnailPath: `/upload/thumbs/person-${index}.jpeg`,
|
|
||||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FaceCreateCapture = {
|
|
||||||
requests: Array<{
|
|
||||||
assetId: string;
|
|
||||||
personId: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
imageWidth: number;
|
|
||||||
imageHeight: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setupFaceEditorMockApiRoutes = async (
|
|
||||||
context: BrowserContext,
|
|
||||||
mockPeople: MockPerson[],
|
|
||||||
faceCreateCapture: FaceCreateCapture,
|
|
||||||
) => {
|
|
||||||
await context.route('**/api/people?*', async (route, request) => {
|
|
||||||
if (request.method() !== 'GET') {
|
|
||||||
return route.fallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
json: {
|
|
||||||
hasNextPage: false,
|
|
||||||
hidden: 0,
|
|
||||||
people: mockPeople,
|
|
||||||
total: mockPeople.length,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await context.route('**/api/faces', async (route, request) => {
|
|
||||||
if (request.method() !== 'POST') {
|
|
||||||
return route.fallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = request.postDataJSON();
|
|
||||||
faceCreateCapture.requests.push(body);
|
|
||||||
|
|
||||||
return route.fulfill({
|
|
||||||
status: 201,
|
|
||||||
contentType: 'text/plain',
|
|
||||||
body: 'OK',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await context.route('**/api/people/*/thumbnail', async (route) => {
|
|
||||||
if (!route.request().serviceWorker()) {
|
|
||||||
return route.continue();
|
|
||||||
}
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'image/jpeg' },
|
|
||||||
body: await randomThumbnail('person-thumb', 1),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
TimelineData,
|
TimelineData,
|
||||||
} from 'src/ui/generators/timeline';
|
} from 'src/ui/generators/timeline';
|
||||||
import { sleep } from 'src/ui/specs/timeline/utils';
|
import { sleep } from 'src/ui/specs/timeline/utils';
|
||||||
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
|
|
||||||
|
|
||||||
export class TimelineTestContext {
|
export class TimelineTestContext {
|
||||||
slowBucket = false;
|
slowBucket = false;
|
||||||
@@ -136,14 +135,6 @@ export const setupTimelineMockApiRoutes = async (
|
|||||||
return route.continue();
|
return route.continue();
|
||||||
});
|
});
|
||||||
|
|
||||||
await context.route('**/api/assets/*/video/playback*', async (route) => {
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'video/mp4' },
|
|
||||||
body: MINIMAL_MP4_BUFFER,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await context.route('**/api/albums/**', async (route, request) => {
|
await context.route('**/api/albums/**', async (route, request) => {
|
||||||
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
||||||
if (albumsMatch) {
|
if (albumsMatch) {
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
|
||||||
import {
|
|
||||||
createMockStack,
|
|
||||||
createMockStackAsset,
|
|
||||||
MockStack,
|
|
||||||
setupBrokenAssetMockApiRoutes,
|
|
||||||
} from 'src/ui/mock-network/broken-asset-network';
|
|
||||||
import { assetViewerUtils } from '../timeline/utils';
|
|
||||||
import { setupAssetViewerFixture } from './utils';
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
|
||||||
test.describe('broken-asset responsiveness', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(889);
|
|
||||||
let mockStack: MockStack;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
|
||||||
|
|
||||||
const brokenAssets = [
|
|
||||||
createMockStackAsset(fixture.adminUserId),
|
|
||||||
createMockStackAsset(fixture.adminUserId),
|
|
||||||
createMockStackAsset(fixture.adminUserId),
|
|
||||||
];
|
|
||||||
|
|
||||||
mockStack = createMockStack(primaryAssetDto, brokenAssets);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('broken asset in stack strip hides icon at small size', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const stackSlideshow = page.locator('#stack-slideshow');
|
|
||||||
await expect(stackSlideshow).toBeVisible();
|
|
||||||
|
|
||||||
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
|
|
||||||
await expect(brokenAssets.first()).toBeVisible();
|
|
||||||
await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size);
|
|
||||||
|
|
||||||
for (const brokenAsset of await brokenAssets.all()) {
|
|
||||||
await expect(brokenAsset.locator('svg')).not.toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('broken asset in stack strip uses text-xs class', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const stackSlideshow = page.locator('#stack-slideshow');
|
|
||||||
await expect(stackSlideshow).toBeVisible();
|
|
||||||
|
|
||||||
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
|
|
||||||
await expect(brokenAssets.first()).toBeVisible();
|
|
||||||
|
|
||||||
for (const brokenAsset of await brokenAssets.all()) {
|
|
||||||
const messageSpan = brokenAsset.locator('span');
|
|
||||||
await expect(messageSpan).toHaveClass(/text-xs/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
|
|
||||||
await context.route(
|
|
||||||
(url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
|
|
||||||
async (route) => {
|
|
||||||
return route.fulfill({ status: 404 });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await page.waitForSelector('#immich-asset-viewer');
|
|
||||||
|
|
||||||
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
|
|
||||||
await expect(viewerBrokenAsset).toBeVisible();
|
|
||||||
|
|
||||||
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
|
||||||
|
|
||||||
const messageSpan = viewerBrokenAsset.locator('span');
|
|
||||||
await expect(messageSpan).toHaveClass(/text-base/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
import { expect, Page, test } from '@playwright/test';
|
|
||||||
import { SeededRandom, selectRandom, TimelineAssetConfig } from 'src/ui/generators/timeline';
|
|
||||||
import {
|
|
||||||
createMockPeople,
|
|
||||||
FaceCreateCapture,
|
|
||||||
MockPerson,
|
|
||||||
setupFaceEditorMockApiRoutes,
|
|
||||||
} from 'src/ui/mock-network/face-editor-network';
|
|
||||||
import { assetViewerUtils } from '../timeline/utils';
|
|
||||||
import { setupAssetViewerFixture } from './utils';
|
|
||||||
|
|
||||||
const waitForSelectorTransition = async (page: Page) => {
|
|
||||||
await page.waitForFunction(
|
|
||||||
() => {
|
|
||||||
const selector = document.querySelector('#face-selector') as HTMLElement | null;
|
|
||||||
if (!selector) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ timeout: 1000, polling: 50 },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openFaceEditor = async (page: Page, asset: TimelineAssetConfig) => {
|
|
||||||
await page.goto(`/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
await page.keyboard.press('i');
|
|
||||||
await page.locator('#detail-panel').waitFor({ state: 'visible' });
|
|
||||||
await page.getByLabel('Tag people').click();
|
|
||||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
|
||||||
await waitForSelectorTransition(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
|
||||||
test.describe('face-editor', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(777);
|
|
||||||
const rng = new SeededRandom(777);
|
|
||||||
let mockPeople: MockPerson[];
|
|
||||||
let faceCreateCapture: FaceCreateCapture;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
mockPeople = createMockPeople(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
faceCreateCapture = { requests: [] };
|
|
||||||
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
|
|
||||||
});
|
|
||||||
|
|
||||||
type ScreenRect = { top: number; left: number; width: number; height: number };
|
|
||||||
|
|
||||||
const getFaceBoxRect = async (page: Page): Promise<ScreenRect> => {
|
|
||||||
const dataEl = page.locator('#face-editor-data');
|
|
||||||
await expect(dataEl).toHaveAttribute('data-face-left', /^-?\d+/);
|
|
||||||
await expect(dataEl).toHaveAttribute('data-face-top', /^-?\d+/);
|
|
||||||
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
|
|
||||||
await expect(dataEl).toHaveAttribute('data-face-height', /^[1-9]/);
|
|
||||||
const canvasBox = await page.locator('#face-editor').boundingBox();
|
|
||||||
if (!canvasBox) {
|
|
||||||
throw new Error('Canvas element not found');
|
|
||||||
}
|
|
||||||
const left = Number(await dataEl.getAttribute('data-face-left'));
|
|
||||||
const top = Number(await dataEl.getAttribute('data-face-top'));
|
|
||||||
const width = Number(await dataEl.getAttribute('data-face-width'));
|
|
||||||
const height = Number(await dataEl.getAttribute('data-face-height'));
|
|
||||||
return {
|
|
||||||
top: canvasBox.y + top,
|
|
||||||
left: canvasBox.x + left,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSelectorRect = async (page: Page): Promise<ScreenRect> => {
|
|
||||||
const box = await page.locator('#face-selector').boundingBox();
|
|
||||||
if (!box) {
|
|
||||||
throw new Error('Face selector element not found');
|
|
||||||
}
|
|
||||||
return { top: box.y, left: box.x, width: box.width, height: box.height };
|
|
||||||
};
|
|
||||||
|
|
||||||
const computeOverlapArea = (a: ScreenRect, b: ScreenRect): number => {
|
|
||||||
const overlapX = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left));
|
|
||||||
const overlapY = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
|
|
||||||
return overlapX * overlapY;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dragFaceBox = async (page: Page, deltaX: number, deltaY: number) => {
|
|
||||||
const faceBox = await getFaceBoxRect(page);
|
|
||||||
const centerX = faceBox.left + faceBox.width / 2;
|
|
||||||
const centerY = faceBox.top + faceBox.height / 2;
|
|
||||||
await page.mouse.move(centerX, centerY);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
|
|
||||||
await page.mouse.up();
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
};
|
|
||||||
|
|
||||||
test('Face editor opens with person list', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
await expect(page.locator('#face-selector')).toBeVisible();
|
|
||||||
await expect(page.locator('#face-editor')).toBeVisible();
|
|
||||||
|
|
||||||
for (const person of mockPeople) {
|
|
||||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Search filters people by name', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
const searchInput = page.locator('#face-selector input');
|
|
||||||
await searchInput.fill('Alice');
|
|
||||||
|
|
||||||
await expect(page.locator('#face-selector').getByText('Alice Johnson')).toBeVisible();
|
|
||||||
await expect(page.locator('#face-selector').getByText('Bob Smith')).toBeHidden();
|
|
||||||
|
|
||||||
await searchInput.clear();
|
|
||||||
|
|
||||||
for (const person of mockPeople) {
|
|
||||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Search with no results shows empty message', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
const searchInput = page.locator('#face-selector input');
|
|
||||||
await searchInput.fill('Nonexistent Person XYZ');
|
|
||||||
|
|
||||||
for (const person of mockPeople) {
|
|
||||||
await expect(page.locator('#face-selector').getByText(person.name)).toBeHidden();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Selecting a person shows confirmation dialog', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
const personToTag = mockPeople[0];
|
|
||||||
await page.locator('#face-selector').getByText(personToTag.name).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Confirming tag calls createFace API and closes editor', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
const personToTag = mockPeople[0];
|
|
||||||
await page.locator('#face-selector').getByText(personToTag.name).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
|
||||||
await page.getByRole('button', { name: /confirm/i }).click();
|
|
||||||
|
|
||||||
await expect(page.locator('#face-selector')).toBeHidden();
|
|
||||||
await expect(page.locator('#face-editor')).toBeHidden();
|
|
||||||
|
|
||||||
expect(faceCreateCapture.requests).toHaveLength(1);
|
|
||||||
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
|
|
||||||
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Cancel button closes face editor', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
await expect(page.locator('#face-selector')).toBeVisible();
|
|
||||||
await expect(page.locator('#face-editor')).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /cancel/i }).click();
|
|
||||||
|
|
||||||
await expect(page.locator('#face-selector')).toBeHidden();
|
|
||||||
await expect(page.locator('#face-editor')).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Selector does not overlap face box on initial open', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
const faceBox = await getFaceBoxRect(page);
|
|
||||||
const selectorBox = await getSelectorRect(page);
|
|
||||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
|
||||||
|
|
||||||
expect(overlap).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Selector repositions without overlap after dragging face box down', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
await dragFaceBox(page, 0, 150);
|
|
||||||
|
|
||||||
const faceBox = await getFaceBoxRect(page);
|
|
||||||
const selectorBox = await getSelectorRect(page);
|
|
||||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
|
||||||
|
|
||||||
expect(overlap).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Selector repositions without overlap after dragging face box right', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
await dragFaceBox(page, 200, 0);
|
|
||||||
|
|
||||||
const faceBox = await getFaceBoxRect(page);
|
|
||||||
const selectorBox = await getSelectorRect(page);
|
|
||||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
|
||||||
|
|
||||||
expect(overlap).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Selector repositions without overlap after dragging face box to top-left corner', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
await dragFaceBox(page, -300, -300);
|
|
||||||
|
|
||||||
const faceBox = await getFaceBoxRect(page);
|
|
||||||
const selectorBox = await getSelectorRect(page);
|
|
||||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
|
||||||
|
|
||||||
expect(overlap).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Selector repositions without overlap after dragging face box to bottom-right', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
await dragFaceBox(page, 300, 300);
|
|
||||||
|
|
||||||
const faceBox = await getFaceBoxRect(page);
|
|
||||||
const selectorBox = await getSelectorRect(page);
|
|
||||||
const overlap = computeOverlapArea(faceBox, selectorBox);
|
|
||||||
|
|
||||||
expect(overlap).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Selector stays within viewport bounds', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
const viewportSize = page.viewportSize()!;
|
|
||||||
const selectorBox = await getSelectorRect(page);
|
|
||||||
|
|
||||||
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
|
||||||
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Selector stays within viewport after dragging to edge', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
await dragFaceBox(page, -400, -400);
|
|
||||||
|
|
||||||
const viewportSize = page.viewportSize()!;
|
|
||||||
const selectorBox = await getSelectorRect(page);
|
|
||||||
|
|
||||||
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
|
||||||
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Face box is draggable on the canvas', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
const beforeDrag = await getFaceBoxRect(page);
|
|
||||||
await dragFaceBox(page, 100, 50);
|
|
||||||
const afterDrag = await getFaceBoxRect(page);
|
|
||||||
|
|
||||||
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
|
|
||||||
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { faker } from '@faker-js/faker';
|
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
|
||||||
import {
|
|
||||||
createMockStack,
|
|
||||||
createMockStackAsset,
|
|
||||||
MockStack,
|
|
||||||
setupBrokenAssetMockApiRoutes,
|
|
||||||
} from 'src/ui/mock-network/broken-asset-network';
|
|
||||||
import { assetViewerUtils } from '../timeline/utils';
|
|
||||||
import { enableTagsPreference, ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
|
||||||
test.describe('asset-viewer stack', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(888);
|
|
||||||
let mockStack: MockStack;
|
|
||||||
let primaryAssetDto: AssetResponseDto;
|
|
||||||
let secondAssetDto: AssetResponseDto;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
|
||||||
primaryAssetDto.tags = [
|
|
||||||
{
|
|
||||||
id: faker.string.uuid(),
|
|
||||||
name: '1',
|
|
||||||
value: 'test/1',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
secondAssetDto = createMockStackAsset(fixture.adminUserId);
|
|
||||||
secondAssetDto.tags = [
|
|
||||||
{
|
|
||||||
id: faker.string.uuid(),
|
|
||||||
name: '2',
|
|
||||||
value: 'test/2',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stack slideshow is visible', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const stackSlideshow = page.locator('#stack-slideshow');
|
|
||||||
await expect(stackSlideshow).toBeVisible();
|
|
||||||
|
|
||||||
const stackAssets = stackSlideshow.locator('[data-asset]');
|
|
||||||
await expect(stackAssets).toHaveCount(mockStack.assets.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tags of primary asset are visible', async ({ context, page }) => {
|
|
||||||
await enableTagsPreference(context);
|
|
||||||
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
|
|
||||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
|
||||||
await expect(tags.first()).toHaveText('test/1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tags of second asset are visible', async ({ context, page }) => {
|
|
||||||
await enableTagsPreference(context);
|
|
||||||
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
|
|
||||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
|
||||||
await stackAssets.nth(1).click();
|
|
||||||
|
|
||||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
|
||||||
await expect(tags.first()).toHaveText('test/2');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { faker } from '@faker-js/faker';
|
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
|
||||||
import { BrowserContext, Page, test } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
Changes,
|
|
||||||
createDefaultTimelineConfig,
|
|
||||||
generateTimelineData,
|
|
||||||
SeededRandom,
|
|
||||||
selectRandom,
|
|
||||||
TimelineAssetConfig,
|
|
||||||
TimelineData,
|
|
||||||
toAssetResponseDto,
|
|
||||||
} from 'src/ui/generators/timeline';
|
|
||||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
|
||||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
|
||||||
import { utils } from 'src/utils';
|
|
||||||
|
|
||||||
export type AssetViewerTestFixture = {
|
|
||||||
adminUserId: string;
|
|
||||||
timelineRestData: TimelineData;
|
|
||||||
assets: TimelineAssetConfig[];
|
|
||||||
testContext: TimelineTestContext;
|
|
||||||
changes: Changes;
|
|
||||||
primaryAsset: TimelineAssetConfig;
|
|
||||||
primaryAssetDto: AssetResponseDto;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function setupAssetViewerFixture(seed: number): AssetViewerTestFixture {
|
|
||||||
const rng = new SeededRandom(seed);
|
|
||||||
const testContext = new TimelineTestContext();
|
|
||||||
|
|
||||||
const fixture: AssetViewerTestFixture = {
|
|
||||||
adminUserId: undefined!,
|
|
||||||
timelineRestData: undefined!,
|
|
||||||
assets: [],
|
|
||||||
testContext,
|
|
||||||
changes: {
|
|
||||||
albumAdditions: [],
|
|
||||||
assetDeletions: [],
|
|
||||||
assetArchivals: [],
|
|
||||||
assetFavorites: [],
|
|
||||||
},
|
|
||||||
primaryAsset: undefined!,
|
|
||||||
primaryAssetDto: undefined!,
|
|
||||||
};
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
test.fail(
|
|
||||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
|
|
||||||
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
|
|
||||||
);
|
|
||||||
utils.initSdk();
|
|
||||||
fixture.adminUserId = faker.string.uuid();
|
|
||||||
testContext.adminId = fixture.adminUserId;
|
|
||||||
fixture.timelineRestData = generateTimelineData({
|
|
||||||
...createDefaultTimelineConfig(),
|
|
||||||
ownerId: fixture.adminUserId,
|
|
||||||
});
|
|
||||||
for (const timeBucket of fixture.timelineRestData.buckets.values()) {
|
|
||||||
fixture.assets.push(...timeBucket);
|
|
||||||
}
|
|
||||||
|
|
||||||
fixture.primaryAsset = selectRandom(
|
|
||||||
fixture.assets.filter((a) => a.isImage),
|
|
||||||
rng,
|
|
||||||
);
|
|
||||||
fixture.primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
await setupBaseMockApiRoutes(context, fixture.adminUserId);
|
|
||||||
await setupTimelineMockApiRoutes(context, fixture.timelineRestData, fixture.changes, fixture.testContext);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(() => {
|
|
||||||
fixture.testContext.slowBucket = false;
|
|
||||||
fixture.changes.albumAdditions = [];
|
|
||||||
fixture.changes.assetDeletions = [];
|
|
||||||
fixture.changes.assetArchivals = [];
|
|
||||||
fixture.changes.assetFavorites = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
return fixture;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureDetailPanelVisible(page: Page) {
|
|
||||||
await page.waitForSelector('#immich-asset-viewer');
|
|
||||||
|
|
||||||
const isVisible = await page.locator('#detail-panel').isVisible();
|
|
||||||
if (!isVisible) {
|
|
||||||
await page.keyboard.press('i');
|
|
||||||
await page.waitForSelector('#detail-panel');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enableTagsPreference(context: BrowserContext) {
|
|
||||||
await context.route('**/users/me/preferences', async (route) => {
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
json: {
|
|
||||||
albums: { defaultAssetOrder: 'desc' },
|
|
||||||
folders: { enabled: false, sidebarWeb: false },
|
|
||||||
memories: { enabled: true, duration: 5 },
|
|
||||||
people: { enabled: true, sidebarWeb: false },
|
|
||||||
sharedLinks: { enabled: true, sidebarWeb: false },
|
|
||||||
ratings: { enabled: false },
|
|
||||||
tags: { enabled: true, sidebarWeb: false },
|
|
||||||
emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true },
|
|
||||||
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
|
|
||||||
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
|
|
||||||
cast: { gCastEnabled: false },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -438,7 +438,7 @@ test.describe('Timeline', () => {
|
|||||||
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||||
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
|
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
|
||||||
});
|
});
|
||||||
test('Add photos to album', async ({ page }) => {
|
test('Add photos to album', async ({ page }) => {
|
||||||
const album = timelineRestData.album;
|
const album = timelineRestData.album;
|
||||||
@@ -447,7 +447,7 @@ test.describe('Timeline', () => {
|
|||||||
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
const asset = getAsset(timelineRestData, album.assetIds[0])!;
|
||||||
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
await pageUtils.goToAsset(page, asset.fileCreatedAt);
|
||||||
await thumbnailUtils.expectInViewport(page, asset.id);
|
await thumbnailUtils.expectInViewport(page, asset.id);
|
||||||
await thumbnailUtils.expectSelectedDisabled(page, asset.id);
|
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
|
||||||
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
|
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
|
||||||
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
|
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
|
||||||
const requestJson = request.postDataJSON();
|
const requestJson = request.postDataJSON();
|
||||||
|
|||||||
@@ -102,9 +102,9 @@ export const thumbnailUtils = {
|
|||||||
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
|
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
|
||||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
||||||
},
|
},
|
||||||
async expectSelectedDisabled(page: Page, assetId: string) {
|
async expectSelectedReadonly(page: Page, assetId: string) {
|
||||||
await expect(
|
await expect(
|
||||||
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected][data-disabled]`),
|
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
},
|
},
|
||||||
async expectTimelineHasOnScreenAssets(page: Page) {
|
async expectTimelineHasOnScreenAssets(page: Page) {
|
||||||
|
|||||||
+1
-1
@@ -17,6 +17,6 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"baseUrl": "./"
|
"baseUrl": "./"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "vitest*.config.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||||
@@ -15,14 +14,15 @@ if (!skipDockerSetup) {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
name: 'e2e:server',
|
|
||||||
retry: process.env.CI ? 4 : 0,
|
retry: process.env.CI ? 4 : 0,
|
||||||
include: ['src/specs/server/**/*.e2e-spec.ts'],
|
include: ['src/specs/server/**/*.e2e-spec.ts'],
|
||||||
globalSetup,
|
globalSetup,
|
||||||
testTimeout: 15_000,
|
testTimeout: 15_000,
|
||||||
pool: 'threads',
|
pool: 'threads',
|
||||||
maxWorkers: 1,
|
poolOptions: {
|
||||||
isolate: false,
|
threads: {
|
||||||
|
singleThread: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [tsconfigPaths()],
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
const skipDockerSetup = process.env.VITEST_DISABLE_DOCKER_SETUP === 'true';
|
||||||
@@ -15,14 +14,15 @@ if (!skipDockerSetup) {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
name: 'e2e:maintenance',
|
|
||||||
retry: process.env.CI ? 4 : 0,
|
retry: process.env.CI ? 4 : 0,
|
||||||
include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'],
|
include: ['src/specs/maintenance/server/**/*.e2e-spec.ts'],
|
||||||
globalSetup,
|
globalSetup,
|
||||||
testTimeout: 15_000,
|
testTimeout: 15_000,
|
||||||
pool: 'threads',
|
pool: 'threads',
|
||||||
maxWorkers: 1,
|
poolOptions: {
|
||||||
isolate: false,
|
threads: {
|
||||||
|
singleThread: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [tsconfigPaths()],
|
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-16
@@ -411,7 +411,7 @@
|
|||||||
"transcoding_tone_mapping": "Tone-mapping",
|
"transcoding_tone_mapping": "Tone-mapping",
|
||||||
"transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.",
|
"transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.",
|
||||||
"transcoding_transcode_policy": "Transcode policy",
|
"transcoding_transcode_policy": "Transcode policy",
|
||||||
"transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos and videos with a pixel format other than YUV 4:2:0 will always be transcoded (except if transcoding is disabled).",
|
"transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).",
|
||||||
"transcoding_two_pass_encoding": "Two-pass encoding",
|
"transcoding_two_pass_encoding": "Two-pass encoding",
|
||||||
"transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.",
|
"transcoding_two_pass_encoding_setting_description": "Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled.",
|
||||||
"transcoding_video_codec": "Video codec",
|
"transcoding_video_codec": "Video codec",
|
||||||
@@ -871,8 +871,8 @@
|
|||||||
"current_pin_code": "Current PIN code",
|
"current_pin_code": "Current PIN code",
|
||||||
"current_server_address": "Current server address",
|
"current_server_address": "Current server address",
|
||||||
"custom_date": "Custom date",
|
"custom_date": "Custom date",
|
||||||
"custom_locale": "Custom locale",
|
"custom_locale": "Custom Locale",
|
||||||
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
|
"custom_locale_description": "Format dates and numbers based on the language and the region",
|
||||||
"custom_url": "Custom URL",
|
"custom_url": "Custom URL",
|
||||||
"cutoff_date_description": "Keep photos from the last…",
|
"cutoff_date_description": "Keep photos from the last…",
|
||||||
"cutoff_day": "{count, plural, one {day} other {days}}",
|
"cutoff_day": "{count, plural, one {day} other {days}}",
|
||||||
@@ -895,6 +895,8 @@
|
|||||||
"deduplication_criteria_2": "Count of EXIF data",
|
"deduplication_criteria_2": "Count of EXIF data",
|
||||||
"deduplication_info": "Deduplication Info",
|
"deduplication_info": "Deduplication Info",
|
||||||
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
|
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
|
||||||
|
"default_locale": "Default Locale",
|
||||||
|
"default_locale_description": "Format dates and numbers based on your browser locale",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
||||||
"delete_action_prompt": "{count} deleted",
|
"delete_action_prompt": "{count} deleted",
|
||||||
@@ -1072,7 +1074,6 @@
|
|||||||
"failed_to_update_notification_status": "Failed to update notification status",
|
"failed_to_update_notification_status": "Failed to update notification status",
|
||||||
"incorrect_email_or_password": "Incorrect email or password",
|
"incorrect_email_or_password": "Incorrect email or password",
|
||||||
"library_folder_already_exists": "This import path already exists.",
|
"library_folder_already_exists": "This import path already exists.",
|
||||||
"page_not_found": "Page not found :/",
|
|
||||||
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
|
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
|
||||||
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
|
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
|
||||||
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
|
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
|
||||||
@@ -1808,8 +1809,9 @@
|
|||||||
"rate_asset": "Rate Asset",
|
"rate_asset": "Rate Asset",
|
||||||
"rating": "Star rating",
|
"rating": "Star rating",
|
||||||
"rating_clear": "Clear rating",
|
"rating_clear": "Clear rating",
|
||||||
"rating_count": "{count, plural, =0 {Unrated} one {# star} other {# stars}}",
|
"rating_count": "{count, plural, one {# star} other {# stars}}",
|
||||||
"rating_description": "Display the EXIF rating in the info panel",
|
"rating_description": "Display the EXIF rating in the info panel",
|
||||||
|
"rating_set": "Rating set to {rating, plural, one {# star} other {# stars}}",
|
||||||
"reaction_options": "Reaction options",
|
"reaction_options": "Reaction options",
|
||||||
"read_changelog": "Read Changelog",
|
"read_changelog": "Read Changelog",
|
||||||
"readonly_mode_disabled": "Read-only mode disabled",
|
"readonly_mode_disabled": "Read-only mode disabled",
|
||||||
@@ -1881,10 +1883,7 @@
|
|||||||
"reset_pin_code_success": "Successfully reset PIN code",
|
"reset_pin_code_success": "Successfully reset PIN code",
|
||||||
"reset_pin_code_with_password": "You can always reset your PIN code with your password",
|
"reset_pin_code_with_password": "You can always reset your PIN code with your password",
|
||||||
"reset_sqlite": "Reset SQLite Database",
|
"reset_sqlite": "Reset SQLite Database",
|
||||||
"reset_sqlite_clear_app_data": "Clear Data",
|
"reset_sqlite_confirmation": "Are you sure you want to reset the SQLite database? You will need to log out and log in again to resync the data",
|
||||||
"reset_sqlite_confirmation": "Are you sure you want to clear the app data? This will remove all settings and sign you out.",
|
|
||||||
"reset_sqlite_confirmation_note": "Note: You will need to restart the app after clearing.",
|
|
||||||
"reset_sqlite_done": "App data has been cleared. Please restart Immich and log in again.",
|
|
||||||
"reset_sqlite_success": "Successfully reset the SQLite database",
|
"reset_sqlite_success": "Successfully reset the SQLite database",
|
||||||
"reset_to_default": "Reset to default",
|
"reset_to_default": "Reset to default",
|
||||||
"resolution": "Resolution",
|
"resolution": "Resolution",
|
||||||
@@ -1912,7 +1911,6 @@
|
|||||||
"saved_settings": "Saved settings",
|
"saved_settings": "Saved settings",
|
||||||
"say_something": "Say something",
|
"say_something": "Say something",
|
||||||
"scaffold_body_error_occurred": "Error occurred",
|
"scaffold_body_error_occurred": "Error occurred",
|
||||||
"scaffold_body_error_unrecoverable": "An unrecoverable error has occurred. Please share the error and stack trace on Discord or GitHub so we can help. If advised, you can clear the app data below.",
|
|
||||||
"scan": "Scan",
|
"scan": "Scan",
|
||||||
"scan_all_libraries": "Scan All Libraries",
|
"scan_all_libraries": "Scan All Libraries",
|
||||||
"scan_library": "Scan",
|
"scan_library": "Scan",
|
||||||
@@ -2028,9 +2026,6 @@
|
|||||||
"set_profile_picture": "Set profile picture",
|
"set_profile_picture": "Set profile picture",
|
||||||
"set_slideshow_to_fullscreen": "Set Slideshow to fullscreen",
|
"set_slideshow_to_fullscreen": "Set Slideshow to fullscreen",
|
||||||
"set_stack_primary_asset": "Set as primary asset",
|
"set_stack_primary_asset": "Set as primary asset",
|
||||||
"setting_image_navigation_enable_subtitle": "If enabled, you can navigate to the previous/next image by tapping the leftmost/rightmost quarter of the screen.",
|
|
||||||
"setting_image_navigation_enable_title": "Tap to Navigate",
|
|
||||||
"setting_image_navigation_title": "Image Navigation",
|
|
||||||
"setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).",
|
"setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).",
|
||||||
"setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).",
|
"setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).",
|
||||||
"setting_image_viewer_original_title": "Load original image",
|
"setting_image_viewer_original_title": "Load original image",
|
||||||
@@ -2309,7 +2304,6 @@
|
|||||||
"unstack_action_prompt": "{count} unstacked",
|
"unstack_action_prompt": "{count} unstacked",
|
||||||
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
|
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
|
||||||
"unsupported_field_type": "Unsupported field type",
|
"unsupported_field_type": "Unsupported field type",
|
||||||
"unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.",
|
|
||||||
"untagged": "Untagged",
|
"untagged": "Untagged",
|
||||||
"untitled_workflow": "Untitled workflow",
|
"untitled_workflow": "Untitled workflow",
|
||||||
"up_next": "Up next",
|
"up_next": "Up next",
|
||||||
@@ -2336,8 +2330,6 @@
|
|||||||
"url": "URL",
|
"url": "URL",
|
||||||
"usage": "Usage",
|
"usage": "Usage",
|
||||||
"use_biometric": "Use biometric",
|
"use_biometric": "Use biometric",
|
||||||
"use_browser_locale": "Use browser locale",
|
|
||||||
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
|
|
||||||
"use_current_connection": "Use current connection",
|
"use_current_connection": "Use current connection",
|
||||||
"use_custom_date_range": "Use custom date range instead",
|
"use_custom_date_range": "Use custom date range instead",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@
|
|||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "prettier --cache --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --cache --write --list-different ."
|
"format:fix": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
3.13
|
|
||||||
+50
-10
@@ -22,7 +22,48 @@ FROM builder-cpu AS builder-rknn
|
|||||||
|
|
||||||
# Warning: 25GiB+ disk space required to pull this image
|
# Warning: 25GiB+ disk space required to pull this image
|
||||||
# TODO: find a way to reduce the image size
|
# TODO: find a way to reduce the image size
|
||||||
FROM rocm/dev-ubuntu-24.04:7.2-complete@sha256:86e11093b4a7ec2a79b1b6701d10e840a6994f21c7e05929b51eb9be361c683a AS builder-rocm
|
FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS builder-rocm
|
||||||
|
|
||||||
|
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
|
||||||
|
ARG ONNXRUNTIME_VERSION="v1.22.1"
|
||||||
|
WORKDIR /code
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends wget git
|
||||||
|
RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.31.9/cmake-3.31.9-linux-x86_64.sh && \
|
||||||
|
chmod +x cmake-3.31.9-linux-x86_64.sh && \
|
||||||
|
mkdir -p /code/cmake-3.31.9-linux-x86_64 && \
|
||||||
|
./cmake-3.31.9-linux-x86_64.sh --skip-license --prefix=/code/cmake-3.31.9-linux-x86_64 && \
|
||||||
|
rm cmake-3.31.9-linux-x86_64.sh
|
||||||
|
|
||||||
|
RUN git clone --single-branch --branch "${ONNXRUNTIME_VERSION}" --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime
|
||||||
|
WORKDIR /code/onnxruntime
|
||||||
|
# Fix for multi-threading based on comments in https://github.com/microsoft/onnxruntime/pull/19567
|
||||||
|
# TODO: find a way to fix this without disabling algo caching
|
||||||
|
COPY ./patches/* /tmp/
|
||||||
|
RUN git apply /tmp/*.patch
|
||||||
|
|
||||||
|
RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh
|
||||||
|
|
||||||
|
ENV PATH=/opt/rocm-venv/bin:/code/cmake-3.31.9-linux-x86_64/bin:${PATH}
|
||||||
|
ENV CCACHE_DIR="/ccache"
|
||||||
|
# Note: the `parallel` setting uses a substantial amount of RAM
|
||||||
|
RUN --mount=type=cache,target=/ccache \
|
||||||
|
./build.sh \
|
||||||
|
--allow_running_as_root \
|
||||||
|
--config Release \
|
||||||
|
--build_wheel \
|
||||||
|
--update \
|
||||||
|
--build \
|
||||||
|
--parallel 48 \
|
||||||
|
--cmake_extra_defines \
|
||||||
|
ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" \
|
||||||
|
CMAKE_HIP_ARCHITECTURES="gfx900;gfx906;gfx908;gfx90a;gfx940;gfx941;gfx942;gfx1030;gfx1100;gfx1101;gfx1102;gfx1200;gfx1201" \
|
||||||
|
--skip_tests \
|
||||||
|
--use_rocm \
|
||||||
|
--rocm_home=/opt/rocm \
|
||||||
|
--use_cache \
|
||||||
|
--compile_no_warning_as_error
|
||||||
|
RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/
|
||||||
|
|
||||||
FROM builder-${DEVICE} AS builder
|
FROM builder-${DEVICE} AS builder
|
||||||
|
|
||||||
@@ -38,6 +79,9 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
--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
|
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
|
||||||
|
RUN if [ "$DEVICE" = "rocm" ]; then \
|
||||||
|
uv pip install /opt/onnxruntime_rocm-*.whl; \
|
||||||
|
fi
|
||||||
|
|
||||||
FROM python:3.11-slim-bookworm@sha256:04cd27899595a99dfe77709d96f08876bf2ee99139ee2f0fe9ac948005034e5b AS prod-cpu
|
FROM python:3.11-slim-bookworm@sha256:04cd27899595a99dfe77709d96f08876bf2ee99139ee2f0fe9ac948005034e5b AS prod-cpu
|
||||||
|
|
||||||
@@ -48,14 +92,14 @@ FROM python:3.13-slim-trixie@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c3
|
|||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
|
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-core-2_2.27.10+20617_amd64.deb && \
|
||||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
|
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-opencl-2_2.27.10+20617_amd64.deb && \
|
||||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
|
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/intel-opencl-icd_26.01.36711.4-0_amd64.deb && \
|
||||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
|
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
|
||||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
|
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
|
||||||
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
|
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
|
||||||
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
|
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
|
||||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
|
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/libigdgmm12_22.9.0_amd64.deb && \
|
||||||
dpkg -i *.deb && \
|
dpkg -i *.deb && \
|
||||||
rm *.deb && \
|
rm *.deb && \
|
||||||
apt-get remove wget -yqq && \
|
apt-get remove wget -yqq && \
|
||||||
@@ -76,11 +120,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
|
|||||||
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
|
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
|
||||||
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
|
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
|
||||||
|
|
||||||
FROM rocm/dev-ubuntu-24.04:7.2-complete@sha256:86e11093b4a7ec2a79b1b6701d10e840a6994f21c7e05929b51eb9be361c683a AS prod-rocm
|
FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS prod-rocm
|
||||||
|
|
||||||
RUN apt-get update && apt-get install --no-install-recommends -yqq migraphx miopen-hip && \
|
|
||||||
apt-get clean && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
FROM prod-cpu AS prod-armnn
|
FROM prod-cpu AS prod-armnn
|
||||||
|
|
||||||
|
|||||||
@@ -48,11 +48,8 @@ class PreloadModelData(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MaxBatchSize(BaseModel):
|
class MaxBatchSize(BaseModel):
|
||||||
ocr_fallback: str | None = os.getenv("MACHINE_LEARNING_MAX_BATCH_SIZE__TEXT_RECOGNITION", None)
|
|
||||||
if ocr_fallback is not None:
|
|
||||||
os.environ["MACHINE_LEARNING_MAX_BATCH_SIZE__OCR"] = ocr_fallback
|
|
||||||
facial_recognition: int | None = None
|
facial_recognition: int | None = None
|
||||||
ocr: int | None = None
|
text_recognition: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -82,7 +79,6 @@ class Settings(BaseSettings):
|
|||||||
preload: PreloadModelData | None = None
|
preload: PreloadModelData | None = None
|
||||||
max_batch_size: MaxBatchSize | None = None
|
max_batch_size: MaxBatchSize | None = None
|
||||||
openvino_precision: ModelPrecision = ModelPrecision.FP32
|
openvino_precision: ModelPrecision = ModelPrecision.FP32
|
||||||
rocm_precision: ModelPrecision = ModelPrecision.FP32
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_id(self) -> str:
|
def device_id(self) -> str:
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ _PADDLE_MODELS = {
|
|||||||
|
|
||||||
SUPPORTED_PROVIDERS = [
|
SUPPORTED_PROVIDERS = [
|
||||||
"CUDAExecutionProvider",
|
"CUDAExecutionProvider",
|
||||||
"MIGraphXExecutionProvider",
|
"ROCMExecutionProvider",
|
||||||
"OpenVINOExecutionProvider",
|
"OpenVINOExecutionProvider",
|
||||||
"CoreMLExecutionProvider",
|
"CoreMLExecutionProvider",
|
||||||
"CPUExecutionProvider",
|
"CPUExecutionProvider",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class FaceRecognizer(InferenceModel):
|
|||||||
|
|
||||||
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||||
super().__init__(model_name, **model_kwargs)
|
super().__init__(model_name, **model_kwargs)
|
||||||
max_batch_size = settings.max_batch_size and settings.max_batch_size.facial_recognition
|
max_batch_size = settings.max_batch_size.facial_recognition if settings.max_batch_size else None
|
||||||
self.batch_size = max_batch_size if max_batch_size else self._batch_size_default
|
self.batch_size = max_batch_size if max_batch_size else self._batch_size_default
|
||||||
|
|
||||||
def _load(self) -> ModelSession:
|
def _load(self) -> ModelSession:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class TextDetector(InferenceModel):
|
|||||||
depends = []
|
depends = []
|
||||||
identity = (ModelType.DETECTION, ModelTask.OCR)
|
identity = (ModelType.DETECTION, ModelTask.OCR)
|
||||||
|
|
||||||
def __init__(self, model_name: str, min_score: float = 0.5, **model_kwargs: Any) -> None:
|
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||||
super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX)
|
super().__init__(model_name.split("__")[-1], **model_kwargs, model_format=ModelFormat.ONNX)
|
||||||
self.max_resolution = 736
|
self.max_resolution = 736
|
||||||
self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
|
self.mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
|
||||||
@@ -33,7 +33,7 @@ class TextDetector(InferenceModel):
|
|||||||
}
|
}
|
||||||
self.postprocess = DBPostProcess(
|
self.postprocess = DBPostProcess(
|
||||||
thresh=0.3,
|
thresh=0.3,
|
||||||
box_thresh=model_kwargs.get("minScore", min_score),
|
box_thresh=model_kwargs.get("minScore", 0.5),
|
||||||
max_candidates=1000,
|
max_candidates=1000,
|
||||||
unclip_ratio=1.6,
|
unclip_ratio=1.6,
|
||||||
use_dilation=True,
|
use_dilation=True,
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ class TextRecognizer(InferenceModel):
|
|||||||
depends = [(ModelType.DETECTION, ModelTask.OCR)]
|
depends = [(ModelType.DETECTION, ModelTask.OCR)]
|
||||||
identity = (ModelType.RECOGNITION, ModelTask.OCR)
|
identity = (ModelType.RECOGNITION, ModelTask.OCR)
|
||||||
|
|
||||||
def __init__(self, model_name: str, min_score: float = 0.9, **model_kwargs: Any) -> None:
|
def __init__(self, model_name: str, **model_kwargs: Any) -> None:
|
||||||
self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH
|
self.language = LangRec[model_name.split("__")[0]] if "__" in model_name else LangRec.CH
|
||||||
self.min_score = model_kwargs.get("minScore", min_score)
|
self.min_score = model_kwargs.get("minScore", 0.9)
|
||||||
self._empty: TextRecognitionOutput = {
|
self._empty: TextRecognitionOutput = {
|
||||||
"box": np.empty(0, dtype=np.float32),
|
"box": np.empty(0, dtype=np.float32),
|
||||||
"boxScore": np.empty(0, dtype=np.float32),
|
"boxScore": np.empty(0, dtype=np.float32),
|
||||||
@@ -57,11 +57,10 @@ class TextRecognizer(InferenceModel):
|
|||||||
def _load(self) -> ModelSession:
|
def _load(self) -> ModelSession:
|
||||||
# TODO: support other runtimes
|
# TODO: support other runtimes
|
||||||
session = OrtSession(self.model_path)
|
session = OrtSession(self.model_path)
|
||||||
max_batch_size = settings.max_batch_size and settings.max_batch_size.ocr
|
|
||||||
self.model = RapidTextRecognizer(
|
self.model = RapidTextRecognizer(
|
||||||
OcrOptions(
|
OcrOptions(
|
||||||
session=session.session,
|
session=session.session,
|
||||||
rec_batch_num=max_batch_size if max_batch_size else 6,
|
rec_batch_num=settings.max_batch_size.text_recognition if settings.max_batch_size is not None else 6,
|
||||||
rec_img_shape=(3, 48, 320),
|
rec_img_shape=(3, 48, 320),
|
||||||
lang_type=self.language,
|
lang_type=self.language,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import onnxruntime as ort
|
|||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
from immich_ml.models.constants import SUPPORTED_PROVIDERS
|
from immich_ml.models.constants import SUPPORTED_PROVIDERS
|
||||||
from immich_ml.schemas import ModelPrecision, SessionNode
|
from immich_ml.schemas import SessionNode
|
||||||
|
|
||||||
from ..config import log, settings
|
from ..config import log, settings
|
||||||
|
|
||||||
@@ -90,17 +90,8 @@ class OrtSession:
|
|||||||
match provider:
|
match provider:
|
||||||
case "CPUExecutionProvider":
|
case "CPUExecutionProvider":
|
||||||
options = {"arena_extend_strategy": "kSameAsRequested"}
|
options = {"arena_extend_strategy": "kSameAsRequested"}
|
||||||
case "CUDAExecutionProvider":
|
case "CUDAExecutionProvider" | "ROCMExecutionProvider":
|
||||||
options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id}
|
options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id}
|
||||||
case "MIGraphXExecutionProvider":
|
|
||||||
migraphx_dir = self.model_path.parent / "migraphx"
|
|
||||||
# MIGraphX does not create the underlying folder and will crash if it does not exist
|
|
||||||
migraphx_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
options = {
|
|
||||||
"device_id": settings.device_id,
|
|
||||||
"migraphx_model_cache_dir": migraphx_dir.as_posix(),
|
|
||||||
"migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0",
|
|
||||||
}
|
|
||||||
case "OpenVINOExecutionProvider":
|
case "OpenVINOExecutionProvider":
|
||||||
openvino_dir = self.model_path.parent / "openvino"
|
openvino_dir = self.model_path.parent / "openvino"
|
||||||
device = f"GPU.{settings.device_id}"
|
device = f"GPU.{settings.device_id}"
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
commit 16839b58d9b3c3162a67ce5d776b36d4d24e801f
|
||||||
|
Author: mertalev <101130780+mertalev@users.noreply.github.com>
|
||||||
|
Date: Wed Mar 5 11:25:38 2025 -0500
|
||||||
|
|
||||||
|
disable algo caching (attributed to @dmnieto in https://github.com/microsoft/onnxruntime/pull/19567)
|
||||||
|
|
||||||
|
diff --git a/onnxruntime/core/providers/rocm/nn/conv.cc b/onnxruntime/core/providers/rocm/nn/conv.cc
|
||||||
|
index d7f47d07a8..4060a2af52 100644
|
||||||
|
--- a/onnxruntime/core/providers/rocm/nn/conv.cc
|
||||||
|
+++ b/onnxruntime/core/providers/rocm/nn/conv.cc
|
||||||
|
@@ -127,7 +127,6 @@ Status Conv<T, NHWC>::UpdateState(OpKernelContext* context, bool bias_expected)
|
||||||
|
|
||||||
|
if (w_dims_changed) {
|
||||||
|
s_.last_w_dims = gsl::make_span(w_dims);
|
||||||
|
- s_.cached_benchmark_fwd_results.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
ORT_RETURN_IF_ERROR(conv_attrs_.ValidateInputShape(X->Shape(), W->Shape(), channels_last, channels_last));
|
||||||
|
@@ -277,35 +276,6 @@ Status Conv<T, NHWC>::UpdateState(OpKernelContext* context, bool bias_expected)
|
||||||
|
HIP_CALL_THROW(hipMalloc(&s_.b_zero, malloc_size));
|
||||||
|
HIP_CALL_THROW(hipMemsetAsync(s_.b_zero, 0, malloc_size, Stream(context)));
|
||||||
|
}
|
||||||
|
-
|
||||||
|
- if (!s_.cached_benchmark_fwd_results.contains(x_dims_miopen)) {
|
||||||
|
- miopenConvAlgoPerf_t perf;
|
||||||
|
- int algo_count = 1;
|
||||||
|
- const ROCMExecutionProvider* rocm_ep = static_cast<const ROCMExecutionProvider*>(this->Info().GetExecutionProvider());
|
||||||
|
- static constexpr int num_algos = MIOPEN_CONVOLUTION_FWD_ALGO_COUNT;
|
||||||
|
- size_t max_ws_size = rocm_ep->GetMiopenConvUseMaxWorkspace() ? GetMaxWorkspaceSize(GetMiopenHandle(context), s_, kAllAlgos, num_algos, rocm_ep->GetDeviceId())
|
||||||
|
- : AlgoSearchWorkspaceSize;
|
||||||
|
- IAllocatorUniquePtr<void> algo_search_workspace = GetTransientScratchBuffer<void>(max_ws_size);
|
||||||
|
- MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionForwardAlgorithm(
|
||||||
|
- GetMiopenHandle(context),
|
||||||
|
- s_.x_tensor,
|
||||||
|
- s_.x_data,
|
||||||
|
- s_.w_desc,
|
||||||
|
- s_.w_data,
|
||||||
|
- s_.conv_desc,
|
||||||
|
- s_.y_tensor,
|
||||||
|
- s_.y_data,
|
||||||
|
- 1, // requestedAlgoCount
|
||||||
|
- &algo_count, // returnedAlgoCount
|
||||||
|
- &perf,
|
||||||
|
- algo_search_workspace.get(),
|
||||||
|
- max_ws_size,
|
||||||
|
- false)); // Do not do exhaustive algo search.
|
||||||
|
- s_.cached_benchmark_fwd_results.insert(x_dims_miopen, {perf.fwd_algo, perf.memory});
|
||||||
|
- }
|
||||||
|
- const auto& perf = s_.cached_benchmark_fwd_results.at(x_dims_miopen);
|
||||||
|
- s_.fwd_algo = perf.fwd_algo;
|
||||||
|
- s_.workspace_bytes = perf.memory;
|
||||||
|
} else {
|
||||||
|
// set Y
|
||||||
|
s_.Y = context->Output(0, TensorShape(s_.y_dims));
|
||||||
|
@@ -319,6 +289,31 @@ Status Conv<T, NHWC>::UpdateState(OpKernelContext* context, bool bias_expected)
|
||||||
|
s_.y_data = reinterpret_cast<HipT*>(s_.Y->MutableData<T>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
+
|
||||||
|
+ miopenConvAlgoPerf_t perf;
|
||||||
|
+ int algo_count = 1;
|
||||||
|
+ const ROCMExecutionProvider* rocm_ep = static_cast<const ROCMExecutionProvider*>(this->Info().GetExecutionProvider());
|
||||||
|
+ static constexpr int num_algos = MIOPEN_CONVOLUTION_FWD_ALGO_COUNT;
|
||||||
|
+ size_t max_ws_size = rocm_ep->GetMiopenConvUseMaxWorkspace() ? GetMaxWorkspaceSize(GetMiopenHandle(context), s_, kAllAlgos, num_algos, rocm_ep->GetDeviceId())
|
||||||
|
+ : AlgoSearchWorkspaceSize;
|
||||||
|
+ IAllocatorUniquePtr<void> algo_search_workspace = GetTransientScratchBuffer<void>(max_ws_size);
|
||||||
|
+ MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionForwardAlgorithm(
|
||||||
|
+ GetMiopenHandle(context),
|
||||||
|
+ s_.x_tensor,
|
||||||
|
+ s_.x_data,
|
||||||
|
+ s_.w_desc,
|
||||||
|
+ s_.w_data,
|
||||||
|
+ s_.conv_desc,
|
||||||
|
+ s_.y_tensor,
|
||||||
|
+ s_.y_data,
|
||||||
|
+ 1, // requestedAlgoCount
|
||||||
|
+ &algo_count, // returnedAlgoCount
|
||||||
|
+ &perf,
|
||||||
|
+ algo_search_workspace.get(),
|
||||||
|
+ max_ws_size,
|
||||||
|
+ false)); // Do not do exhaustive algo search.
|
||||||
|
+ s_.fwd_algo = perf.fwd_algo;
|
||||||
|
+ s_.workspace_bytes = perf.memory;
|
||||||
|
return Status::OK();
|
||||||
|
}
|
||||||
|
|
||||||
|
diff --git a/onnxruntime/core/providers/rocm/nn/conv.h b/onnxruntime/core/providers/rocm/nn/conv.h
|
||||||
|
index bc9846203e..d54218f258 100644
|
||||||
|
--- a/onnxruntime/core/providers/rocm/nn/conv.h
|
||||||
|
+++ b/onnxruntime/core/providers/rocm/nn/conv.h
|
||||||
|
@@ -108,9 +108,6 @@ class lru_unordered_map {
|
||||||
|
list_type lru_list_;
|
||||||
|
};
|
||||||
|
|
||||||
|
-// cached miopen descriptors
|
||||||
|
-constexpr size_t MAX_CACHED_ALGO_PERF_RESULTS = 10000;
|
||||||
|
-
|
||||||
|
template <typename AlgoPerfType>
|
||||||
|
struct MiopenConvState {
|
||||||
|
// if x/w dims changed, update algo and miopenTensors
|
||||||
|
@@ -148,9 +145,6 @@ struct MiopenConvState {
|
||||||
|
decltype(AlgoPerfType().memory) memory;
|
||||||
|
};
|
||||||
|
|
||||||
|
- lru_unordered_map<TensorShapeVector, PerfFwdResultParams, vector_hash> cached_benchmark_fwd_results{MAX_CACHED_ALGO_PERF_RESULTS};
|
||||||
|
- lru_unordered_map<TensorShapeVector, PerfBwdResultParams, vector_hash> cached_benchmark_bwd_results{MAX_CACHED_ALGO_PERF_RESULTS};
|
||||||
|
-
|
||||||
|
// Some properties needed to support asymmetric padded Conv nodes
|
||||||
|
bool post_slicing_required;
|
||||||
|
TensorShapeVector slice_starts;
|
||||||
|
diff --git a/onnxruntime/core/providers/rocm/nn/conv_transpose.cc b/onnxruntime/core/providers/rocm/nn/conv_transpose.cc
|
||||||
|
index 7447113fdf..a662e35b2e 100644
|
||||||
|
--- a/onnxruntime/core/providers/rocm/nn/conv_transpose.cc
|
||||||
|
+++ b/onnxruntime/core/providers/rocm/nn/conv_transpose.cc
|
||||||
|
@@ -76,7 +76,6 @@ Status ConvTranspose<T, NHWC>::DoConvTranspose(OpKernelContext* context, bool dy
|
||||||
|
|
||||||
|
if (w_dims_changed) {
|
||||||
|
s_.last_w_dims = gsl::make_span(w_dims);
|
||||||
|
- s_.cached_benchmark_bwd_results.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
ConvTransposeAttributes::Prepare p;
|
||||||
|
@@ -126,35 +125,29 @@ Status ConvTranspose<T, NHWC>::DoConvTranspose(OpKernelContext* context, bool dy
|
||||||
|
}
|
||||||
|
|
||||||
|
y_data = reinterpret_cast<HipT*>(p.Y->MutableData<T>());
|
||||||
|
-
|
||||||
|
- if (!s_.cached_benchmark_bwd_results.contains(x_dims)) {
|
||||||
|
- IAllocatorUniquePtr<void> algo_search_workspace = GetScratchBuffer<void>(AlgoSearchWorkspaceSize, context->GetComputeStream());
|
||||||
|
-
|
||||||
|
- miopenConvAlgoPerf_t perf;
|
||||||
|
- int algo_count = 1;
|
||||||
|
- MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionBackwardDataAlgorithm(
|
||||||
|
- GetMiopenHandle(context),
|
||||||
|
- s_.x_tensor,
|
||||||
|
- x_data,
|
||||||
|
- s_.w_desc,
|
||||||
|
- w_data,
|
||||||
|
- s_.conv_desc,
|
||||||
|
- s_.y_tensor,
|
||||||
|
- y_data,
|
||||||
|
- 1,
|
||||||
|
- &algo_count,
|
||||||
|
- &perf,
|
||||||
|
- algo_search_workspace.get(),
|
||||||
|
- AlgoSearchWorkspaceSize,
|
||||||
|
- false));
|
||||||
|
- s_.cached_benchmark_bwd_results.insert(x_dims, {perf.bwd_data_algo, perf.memory});
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- const auto& perf = s_.cached_benchmark_bwd_results.at(x_dims);
|
||||||
|
- s_.bwd_data_algo = perf.bwd_data_algo;
|
||||||
|
- s_.workspace_bytes = perf.memory;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ IAllocatorUniquePtr<void> algo_search_workspace = GetScratchBuffer<void>(AlgoSearchWorkspaceSize, context->GetComputeStream());
|
||||||
|
+ miopenConvAlgoPerf_t perf;
|
||||||
|
+ int algo_count = 1;
|
||||||
|
+ MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionBackwardDataAlgorithm(
|
||||||
|
+ GetMiopenHandle(context),
|
||||||
|
+ s_.x_tensor,
|
||||||
|
+ x_data,
|
||||||
|
+ s_.w_desc,
|
||||||
|
+ w_data,
|
||||||
|
+ s_.conv_desc,
|
||||||
|
+ s_.y_tensor,
|
||||||
|
+ y_data,
|
||||||
|
+ 1,
|
||||||
|
+ &algo_count,
|
||||||
|
+ &perf,
|
||||||
|
+ algo_search_workspace.get(),
|
||||||
|
+ AlgoSearchWorkspaceSize,
|
||||||
|
+ false));
|
||||||
|
+ s_.bwd_data_algo = perf.bwd_data_algo;
|
||||||
|
+ s_.workspace_bytes = perf.memory;
|
||||||
|
+
|
||||||
|
// The following block will be executed in case there has been no change in the shapes of the
|
||||||
|
// input and the filter compared to the previous run
|
||||||
|
if (!y_data) {
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
diff --git a/dockerfiles/scripts/install_common_deps.sh b/dockerfiles/scripts/install_common_deps.sh
|
||||||
|
index bbb672a99e..0dc652fbda 100644
|
||||||
|
--- a/dockerfiles/scripts/install_common_deps.sh
|
||||||
|
+++ b/dockerfiles/scripts/install_common_deps.sh
|
||||||
|
@@ -8,16 +8,23 @@ apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
libssl-dev \
|
||||||
|
- python3-dev
|
||||||
|
+ python3-dev \
|
||||||
|
+ ccache
|
||||||
|
|
||||||
|
# Dependencies: conda
|
||||||
|
-wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-4.5.11-Linux-x86_64.sh -O ~/miniconda.sh --no-check-certificate && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
|
||||||
|
+wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-py312_25.9.1-1-Linux-x86_64.sh -O ~/miniconda.sh && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
|
||||||
|
rm ~/miniconda.sh
|
||||||
|
/opt/miniconda/bin/conda clean -ya
|
||||||
|
|
||||||
|
-pip install numpy
|
||||||
|
-pip install packaging
|
||||||
|
-pip install "wheel>=0.35.1"
|
||||||
|
+# Dependencies: venv and packages
|
||||||
|
+/opt/miniconda/bin/python3 -m venv /opt/rocm-venv
|
||||||
|
+/opt/rocm-venv/bin/pip install --no-cache-dir --upgrade pip
|
||||||
|
+/opt/rocm-venv/bin/pip install --no-cache-dir \
|
||||||
|
+ "numpy==2.3.4" \
|
||||||
|
+ "packaging==25.0" \
|
||||||
|
+ "wheel==0.45.1" \
|
||||||
|
+ "setuptools==80.9.0"
|
||||||
|
+
|
||||||
|
rm -rf /opt/miniconda/pkgs
|
||||||
|
|
||||||
|
# Dependencies: cmake
|
||||||
@@ -49,10 +49,10 @@ dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
cpu = ["onnxruntime>=1.23.2,<2"]
|
cpu = ["onnxruntime>=1.23.2,<2"]
|
||||||
cuda = ["onnxruntime-gpu>=1.23.2,<2"]
|
cuda = ["onnxruntime-gpu>=1.23.2,<2"]
|
||||||
openvino = ["onnxruntime-openvino>=1.24.1,<2"]
|
openvino = ["onnxruntime-openvino>=1.23.0,<2"]
|
||||||
armnn = ["onnxruntime>=1.23.2,<2"]
|
armnn = ["onnxruntime>=1.23.2,<2"]
|
||||||
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
|
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
|
||||||
rocm = ["onnxruntime-migraphx>=1.23.2,<2"]
|
rocm = []
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
compile-bytecode = true
|
compile-bytecode = true
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from PIL import Image
|
|||||||
from pytest import MonkeyPatch
|
from pytest import MonkeyPatch
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from immich_ml.config import MaxBatchSize, Settings, settings
|
from immich_ml.config import Settings, settings
|
||||||
from immich_ml.main import load, preload_models
|
from immich_ml.main import load, preload_models
|
||||||
from immich_ml.models.base import InferenceModel
|
from immich_ml.models.base import InferenceModel
|
||||||
from immich_ml.models.cache import ModelCache
|
from immich_ml.models.cache import ModelCache
|
||||||
@@ -26,9 +26,6 @@ from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEn
|
|||||||
from immich_ml.models.clip.visual import OpenClipVisualEncoder
|
from immich_ml.models.clip.visual import OpenClipVisualEncoder
|
||||||
from immich_ml.models.facial_recognition.detection import FaceDetector
|
from immich_ml.models.facial_recognition.detection import FaceDetector
|
||||||
from immich_ml.models.facial_recognition.recognition import FaceRecognizer
|
from immich_ml.models.facial_recognition.recognition import FaceRecognizer
|
||||||
from immich_ml.models.ocr.detection import TextDetector
|
|
||||||
from immich_ml.models.ocr.recognition import TextRecognizer
|
|
||||||
from immich_ml.models.ocr.schemas import OcrOptions
|
|
||||||
from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType
|
from immich_ml.schemas import ModelFormat, ModelPrecision, ModelTask, ModelType
|
||||||
from immich_ml.sessions.ann import AnnSession
|
from immich_ml.sessions.ann import AnnSession
|
||||||
from immich_ml.sessions.ort import OrtSession
|
from immich_ml.sessions.ort import OrtSession
|
||||||
@@ -182,7 +179,7 @@ class TestOrtSession:
|
|||||||
OV_EP = ["OpenVINOExecutionProvider", "CPUExecutionProvider"]
|
OV_EP = ["OpenVINOExecutionProvider", "CPUExecutionProvider"]
|
||||||
CUDA_EP_OUT_OF_ORDER = ["CPUExecutionProvider", "CUDAExecutionProvider"]
|
CUDA_EP_OUT_OF_ORDER = ["CPUExecutionProvider", "CUDAExecutionProvider"]
|
||||||
TRT_EP = ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]
|
TRT_EP = ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]
|
||||||
ROCM_EP = ["MIGraphXExecutionProvider", "CPUExecutionProvider"]
|
ROCM_EP = ["ROCMExecutionProvider", "CPUExecutionProvider"]
|
||||||
COREML_EP = ["CoreMLExecutionProvider", "CPUExecutionProvider"]
|
COREML_EP = ["CoreMLExecutionProvider", "CPUExecutionProvider"]
|
||||||
|
|
||||||
@pytest.mark.providers(CPU_EP)
|
@pytest.mark.providers(CPU_EP)
|
||||||
@@ -292,38 +289,12 @@ class TestOrtSession:
|
|||||||
|
|
||||||
assert session.provider_options == [{"arena_extend_strategy": "kSameAsRequested", "device_id": "1"}]
|
assert session.provider_options == [{"arena_extend_strategy": "kSameAsRequested", "device_id": "1"}]
|
||||||
|
|
||||||
def test_sets_provider_options_for_rocm(self, mocker: MockerFixture) -> None:
|
def test_sets_provider_options_for_rocm(self) -> None:
|
||||||
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
|
||||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||||
mkdir = mocker.patch("immich_ml.sessions.ort.Path.mkdir")
|
|
||||||
|
|
||||||
session = OrtSession(model_path, providers=["MIGraphXExecutionProvider"])
|
session = OrtSession("ViT-B-32__openai", providers=["ROCMExecutionProvider"])
|
||||||
|
|
||||||
assert session.provider_options == [
|
assert session.provider_options == [{"arena_extend_strategy": "kSameAsRequested", "device_id": "1"}]
|
||||||
{
|
|
||||||
"device_id": "1",
|
|
||||||
"migraphx_model_cache_dir": "/cache/ViT-B-32__openai/textual/migraphx",
|
|
||||||
"migraphx_fp16_enable": "0",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
mkdir.assert_called_once_with(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
def test_sets_rocm_to_fp16_if_enabled(self, path: mock.Mock, mocker: MockerFixture) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
|
||||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
|
||||||
mocker.patch.object(settings, "rocm_precision", ModelPrecision.FP16)
|
|
||||||
mkdir = mocker.patch("immich_ml.sessions.ort.Path.mkdir")
|
|
||||||
|
|
||||||
session = OrtSession(model_path, providers=["MIGraphXExecutionProvider"])
|
|
||||||
|
|
||||||
assert session.provider_options == [
|
|
||||||
{
|
|
||||||
"device_id": "1",
|
|
||||||
"migraphx_model_cache_dir": "/cache/ViT-B-32__openai/textual/migraphx",
|
|
||||||
"migraphx_fp16_enable": "1",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
mkdir.assert_called_once_with(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
def test_sets_provider_options_kwarg(self) -> None:
|
def test_sets_provider_options_kwarg(self) -> None:
|
||||||
session = OrtSession(
|
session = OrtSession(
|
||||||
@@ -858,78 +829,6 @@ class TestFaceRecognition:
|
|||||||
onnx.load.assert_not_called()
|
onnx.load.assert_not_called()
|
||||||
onnx.save.assert_not_called()
|
onnx.save.assert_not_called()
|
||||||
|
|
||||||
def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None:
|
|
||||||
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2))
|
|
||||||
|
|
||||||
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
|
|
||||||
|
|
||||||
assert recognizer.batch_size == 2
|
|
||||||
|
|
||||||
def test_ignore_other_custom_max_batch_size(self, mocker: MockerFixture) -> None:
|
|
||||||
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=2))
|
|
||||||
|
|
||||||
recognizer = FaceRecognizer("buffalo_l", cache_dir="test_cache")
|
|
||||||
|
|
||||||
assert recognizer.batch_size is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestOcr:
|
|
||||||
def test_set_det_min_score(self, path: mock.Mock) -> None:
|
|
||||||
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
||||||
|
|
||||||
text_detector = TextDetector("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache")
|
|
||||||
|
|
||||||
assert text_detector.postprocess.box_thresh == 0.8
|
|
||||||
|
|
||||||
def test_set_rec_min_score(self, path: mock.Mock) -> None:
|
|
||||||
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
||||||
|
|
||||||
text_recognizer = TextRecognizer("PP-OCRv5_mobile", min_score=0.8, cache_dir="test_cache")
|
|
||||||
|
|
||||||
assert text_recognizer.min_score == 0.8
|
|
||||||
|
|
||||||
def test_set_rec_set_default_max_batch_size(
|
|
||||||
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
|
|
||||||
) -> None:
|
|
||||||
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
||||||
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
|
||||||
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
|
|
||||||
|
|
||||||
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
|
|
||||||
text_recognizer.load()
|
|
||||||
|
|
||||||
rapid_recognizer.assert_called_once_with(
|
|
||||||
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None:
|
|
||||||
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
||||||
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
|
||||||
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
|
|
||||||
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(ocr=4))
|
|
||||||
|
|
||||||
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
|
|
||||||
text_recognizer.load()
|
|
||||||
|
|
||||||
rapid_recognizer.assert_called_once_with(
|
|
||||||
OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320))
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_ignore_other_custom_max_batch_size(
|
|
||||||
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
|
|
||||||
) -> None:
|
|
||||||
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
|
||||||
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
|
||||||
rapid_recognizer = mocker.patch("immich_ml.models.ocr.recognition.RapidTextRecognizer")
|
|
||||||
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=3))
|
|
||||||
|
|
||||||
text_recognizer = TextRecognizer("PP-OCRv5_mobile", cache_dir="test_cache")
|
|
||||||
text_recognizer.load()
|
|
||||||
|
|
||||||
rapid_recognizer.assert_called_once_with(
|
|
||||||
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
class TestCache:
|
class TestCache:
|
||||||
|
|||||||
Generated
+83
-80
@@ -262,6 +262,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coloredlogs"
|
||||||
|
version = "15.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "humanfriendly" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorlog"
|
name = "colorlog"
|
||||||
version = "6.9.0"
|
version = "6.9.0"
|
||||||
@@ -642,6 +654,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/70/25/fab23259a52ece5670dcb8452e1af34b89e6135ecc17cd4b54b4b479eac6/fsspec-2023.12.2-py3-none-any.whl", hash = "sha256:d800d87f72189a745fa3d6b033b9dc4a34ad069f60ca60b943a63599f5501960", size = 168979, upload-time = "2023-12-11T21:19:52.446Z" },
|
{ url = "https://files.pythonhosted.org/packages/70/25/fab23259a52ece5670dcb8452e1af34b89e6135ecc17cd4b54b4b479eac6/fsspec-2023.12.2-py3-none-any.whl", hash = "sha256:d800d87f72189a745fa3d6b033b9dc4a34ad069f60ca60b943a63599f5501960", size = 168979, upload-time = "2023-12-11T21:19:52.446Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ftfy"
|
||||||
|
version = "6.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "wcwidth" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a5/d3/8650919bc3c7c6e90ee3fa7fd618bf373cbbe55dff043bd67353dbb20cd8/ftfy-6.3.1.tar.gz", hash = "sha256:9b3c3d90f84fb267fe64d375a07b7f8912d817cf86009ae134aa03e1819506ec", size = 308927, upload-time = "2024-10-26T00:50:35.149Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/6e/81d47999aebc1b155f81eca4477a616a70f238a2549848c38983f3c22a82/ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083", size = 44821, upload-time = "2024-10-26T00:50:33.425Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gevent"
|
name = "gevent"
|
||||||
version = "24.10.3"
|
version = "24.10.3"
|
||||||
@@ -764,14 +788,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gunicorn"
|
name = "gunicorn"
|
||||||
version = "25.1.0"
|
version = "23.0.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "packaging" },
|
{ name = "packaging" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -874,6 +898,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" },
|
{ url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "humanfriendly"
|
||||||
|
version = "10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.11"
|
version = "3.11"
|
||||||
@@ -903,6 +939,7 @@ source = { editable = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocache" },
|
{ name = "aiocache" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "ftfy" },
|
||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
{ name = "huggingface-hub" },
|
{ name = "huggingface-hub" },
|
||||||
{ name = "insightface" },
|
{ name = "insightface" },
|
||||||
@@ -936,9 +973,6 @@ rknn = [
|
|||||||
{ name = "onnxruntime" },
|
{ name = "onnxruntime" },
|
||||||
{ name = "rknn-toolkit-lite2" },
|
{ name = "rknn-toolkit-lite2" },
|
||||||
]
|
]
|
||||||
rocm = [
|
|
||||||
{ name = "onnxruntime-migraphx" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -984,6 +1018,7 @@ types = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiocache", specifier = ">=0.12.1,<1.0" },
|
{ name = "aiocache", specifier = ">=0.12.1,<1.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.95.2,<1.0" },
|
{ name = "fastapi", specifier = ">=0.95.2,<1.0" },
|
||||||
|
{ name = "ftfy", specifier = ">=6.1.1" },
|
||||||
{ name = "gunicorn", specifier = ">=21.1.0" },
|
{ name = "gunicorn", specifier = ">=21.1.0" },
|
||||||
{ name = "huggingface-hub", specifier = ">=0.20.1,<1.0" },
|
{ name = "huggingface-hub", specifier = ">=0.20.1,<1.0" },
|
||||||
{ name = "insightface", specifier = ">=0.7.3,<1.0" },
|
{ name = "insightface", specifier = ">=0.7.3,<1.0" },
|
||||||
@@ -992,8 +1027,7 @@ requires-dist = [
|
|||||||
{ name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2,<2" },
|
{ name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2,<2" },
|
||||||
{ name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.23.2,<2" },
|
{ name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.23.2,<2" },
|
||||||
{ name = "onnxruntime-gpu", marker = "extra == 'cuda'", specifier = ">=1.23.2,<2" },
|
{ name = "onnxruntime-gpu", marker = "extra == 'cuda'", specifier = ">=1.23.2,<2" },
|
||||||
{ name = "onnxruntime-migraphx", marker = "extra == 'rocm'", specifier = ">=1.23.2,<2" },
|
{ name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.23.0,<2" },
|
||||||
{ name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.24.1,<2" },
|
|
||||||
{ name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" },
|
{ name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" },
|
||||||
{ name = "orjson", specifier = ">=3.9.5" },
|
{ name = "orjson", specifier = ">=3.9.5" },
|
||||||
{ name = "pillow", specifier = ">=12.1.1,<12.2" },
|
{ name = "pillow", specifier = ">=12.1.1,<12.2" },
|
||||||
@@ -1409,55 +1443,32 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "msgpack"
|
name = "msgpack"
|
||||||
version = "1.1.2"
|
version = "1.0.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c2/d5/5662032db1571110b5b51647aed4b56dfbd01bfae789fa566a2be1f385d1/msgpack-1.0.7.tar.gz", hash = "sha256:572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87", size = 166311, upload-time = "2023-09-28T13:20:36.726Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/b3/309de40dc7406b7f3492332c5ee2b492a593c2a9bb97ea48ebf2f5279999/msgpack-1.0.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:576eb384292b139821c41995523654ad82d1916da6a60cff129c715a6223ea84", size = 305096, upload-time = "2023-09-28T13:18:49.678Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/56/a677cd761a2cefb2e3ffe7e684633294dccb161d78e8ea6da9277e45b4a2/msgpack-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:730076207cb816138cf1af7f7237b208340a2c5e749707457d70705715c93b93", size = 235210, upload-time = "2023-09-28T13:18:51.039Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/4e/1ab4a982cbd90f988e49f849fc1212f2c04a59870c59daabf8950617e2aa/msgpack-1.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:85765fdf4b27eb5086f05ac0491090fc76f4f2b28e09d9350c31aac25a5aaff8", size = 231952, upload-time = "2023-09-28T13:18:52.871Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/74/bd02044eb628c7361ad2bd8c1a6147af5c6c2bbceb77b3b1da20f4a8a9c5/msgpack-1.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3476fae43db72bd11f29a5147ae2f3cb22e2f1a91d575ef130d2bf49afd21c46", size = 549511, upload-time = "2023-09-28T13:18:54.422Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/09/dee50913ba5cc047f7fd7162f09453a676e7935c84b3bf3a398e12108677/msgpack-1.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d4c80667de2e36970ebf74f42d1088cc9ee7ef5f4e8c35eee1b40eafd33ca5b", size = 557980, upload-time = "2023-09-28T13:18:56.058Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/a5/78a7d87f5f8ffe4c32167afa15d4957db649bab4822f909d8d765339bbab/msgpack-1.0.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b0bf0effb196ed76b7ad883848143427a73c355ae8e569fa538365064188b8e", size = 545547, upload-time = "2023-09-28T13:18:57.396Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" },
|
{ url = "https://files.pythonhosted.org/packages/d4/53/698c10913947f97f6fe7faad86a34e6aa1b66cea2df6f99105856bd346d9/msgpack-1.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f9a7c509542db4eceed3dcf21ee5267ab565a83555c9b88a8109dcecc4709002", size = 554669, upload-time = "2023-09-28T13:18:58.957Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/3f/9730c6cb574b15d349b80cd8523a7df4b82058528339f952ea1c32ac8a10/msgpack-1.0.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:84b0daf226913133f899ea9b30618722d45feffa67e4fe867b0b5ae83a34060c", size = 583353, upload-time = "2023-09-28T13:19:01.186Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" },
|
{ url = "https://files.pythonhosted.org/packages/4c/bc/dc184d943692671149848438fb3bed3a3de288ce7998cb91bc98f40f201b/msgpack-1.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ec79ff6159dffcc30853b2ad612ed572af86c92b5168aa3fc01a67b0fa40665e", size = 557455, upload-time = "2023-09-28T13:19:03.201Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" },
|
{ url = "https://files.pythonhosted.org/packages/cf/7b/1bc69d4a56c8d2f4f2dfbe4722d40344af9a85b6fb3b09cfb350ba6a42f6/msgpack-1.0.7-cp311-cp311-win32.whl", hash = "sha256:3e7bf4442b310ff154b7bb9d81eb2c016b7d597e364f97d72b1acc3817a0fdc1", size = 216367, upload-time = "2023-09-28T13:19:04.554Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" },
|
{ url = "https://files.pythonhosted.org/packages/b4/3d/c8dd23050eefa3d9b9c5b8329ed3308c2f2f80f65825e9ea4b7fa621cdab/msgpack-1.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:3f0c8c6dfa6605ab8ff0611995ee30d4f9fcff89966cf562733b4008a3d60d82", size = 222860, upload-time = "2023-09-28T13:19:06.397Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/47/20dff6b4512cf3575550c8801bc53fe7d540f4efef9c5c37af51760fcdcf/msgpack-1.0.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f0936e08e0003f66bfd97e74ee530427707297b0d0361247e9b4f59ab78ddc8b", size = 305759, upload-time = "2023-09-28T13:19:08.148Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/8a/34f1726d2c9feccec3d946776e9bce8f20ae09d8b91899fc20b296c942af/msgpack-1.0.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98bbd754a422a0b123c66a4c341de0474cad4a5c10c164ceed6ea090f3563db4", size = 235330, upload-time = "2023-09-28T13:19:09.417Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/f6/e64c72577d6953789c3cb051b059a4b56317056b3c65013952338ed8a34e/msgpack-1.0.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b291f0ee7961a597cbbcc77709374087fa2a9afe7bdb6a40dbbd9b127e79afee", size = 232537, upload-time = "2023-09-28T13:19:10.898Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/75/1ed3a96e12941873fd957e016cc40c0c178861a872bd45e75b9a188eb422/msgpack-1.0.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebbbba226f0a108a7366bf4b59bf0f30a12fd5e75100c630267d94d7f0ad20e5", size = 546561, upload-time = "2023-09-28T13:19:12.779Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" },
|
{ url = "https://files.pythonhosted.org/packages/e5/0a/c6a1390f9c6a31da0fecbbfdb86b1cb39ad302d9e24f9cca3d9e14c364f0/msgpack-1.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e2d69948e4132813b8d1131f29f9101bc2c915f26089a6d632001a5c1349672", size = 559009, upload-time = "2023-09-28T13:19:14.373Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" },
|
{ url = "https://files.pythonhosted.org/packages/a5/74/99f6077754665613ea1f37b3d91c10129f6976b7721ab4d0973023808e5a/msgpack-1.0.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdf38ba2d393c7911ae989c3bbba510ebbcdf4ecbdbfec36272abe350c454075", size = 543882, upload-time = "2023-09-28T13:19:16.277Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/7e/dc0dc8de2bf27743b31691149258f9b1bd4bf3c44c105df3df9b97081cd1/msgpack-1.0.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:993584fc821c58d5993521bfdcd31a4adf025c7d745bbd4d12ccfecf695af5ba", size = 546949, upload-time = "2023-09-28T13:19:18.114Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/61/91bae9474def032f6c333d62889bbeda9e1554c6b123375ceeb1767efd78/msgpack-1.0.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:52700dc63a4676669b341ba33520f4d6e43d3ca58d422e22ba66d1736b0a6e4c", size = 579836, upload-time = "2023-09-28T13:19:19.729Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
|
{ url = "https://files.pythonhosted.org/packages/5d/4d/d98592099d4f18945f89cf3e634dc0cb128bb33b1b93f85a84173d35e181/msgpack-1.0.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e45ae4927759289c30ccba8d9fdce62bb414977ba158286b5ddaf8df2cddb5c5", size = 556587, upload-time = "2023-09-28T13:19:21.666Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/44/6556ffe169bf2c0e974e2ea25fb82a7e55ebcf52a81b03a5e01820de5f84/msgpack-1.0.7-cp312-cp312-win32.whl", hash = "sha256:27dcd6f46a21c18fa5e5deed92a43d4554e3df8d8ca5a47bf0615d6a5f39dbc9", size = 216509, upload-time = "2023-09-28T13:19:23.161Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/c1/63903f30d51d165e132e5221a2a4a1bbfab7508b68131c871d70bffac78a/msgpack-1.0.7-cp312-cp312-win_amd64.whl", hash = "sha256:7687e22a31e976a0e7fc99c2f4d11ca45eff652a81eb8c8085e9609298916dcf", size = 223287, upload-time = "2023-09-28T13:19:25.097Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1704,29 +1715,12 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/6a/94/a3b20276261f5e64dbd72bda656af988282cff01f18c2685953600e2f810/onnxruntime_gpu-1.24.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2cee7e12b0f4813c62f9a48df83fd01d066cc970400c832252cf3c155a6957", size = 252633096, upload-time = "2026-02-05T17:24:53.248Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/94/a3b20276261f5e64dbd72bda656af988282cff01f18c2685953600e2f810/onnxruntime_gpu-1.24.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2cee7e12b0f4813c62f9a48df83fd01d066cc970400c832252cf3c155a6957", size = 252633096, upload-time = "2026-02-05T17:24:53.248Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "onnxruntime-migraphx"
|
|
||||||
version = "1.24.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "flatbuffers" },
|
|
||||||
{ name = "numpy" },
|
|
||||||
{ name = "packaging" },
|
|
||||||
{ name = "protobuf" },
|
|
||||||
{ name = "sympy" },
|
|
||||||
]
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/da/ca7ebc1a8d1193c97ceb9a05fad50f675eb955dc51beb7eb9ba89c8e7db0/onnxruntime_migraphx-1.24.2-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:a2b434fb8880cac2b268950bdf279f33741d29c1f1c5461d27af835e8e288043", size = 20339710, upload-time = "2026-02-21T07:25:13.17Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/2e/8c83ec45a9365b4256495ca55eea30da7f03b02177b6da423c7da1ff5f6a/onnxruntime_migraphx-1.24.2-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:ec814818da952bda3062e26f56c88bb713c00491ef91f86716c8d7346f9bc31b", size = 20341883, upload-time = "2026-02-21T07:25:17.86Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/52/4776ac68dbc46ca02c9a14cc9e5c496017f47a18cedf606cc38f4911b96a/onnxruntime_migraphx-1.24.2-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:20e497538362170af639b03a40249d7ed61b873ac354f20d732b90252206e320", size = 20342422, upload-time = "2026-02-21T07:25:22.526Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/44/db9035204a3363f9c0a4822c68e9a7520c13ef8d261f96b89b1375106dab/onnxruntime_migraphx-1.24.2-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:9d7f1b1a2b9651143a2080b4f42ee99eead02023de1855d1b8a02199a9c179aa", size = 20343783, upload-time = "2026-02-21T07:25:29.155Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "onnxruntime-openvino"
|
name = "onnxruntime-openvino"
|
||||||
version = "1.24.1"
|
version = "1.23.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "coloredlogs" },
|
||||||
{ name = "flatbuffers" },
|
{ name = "flatbuffers" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "packaging" },
|
{ name = "packaging" },
|
||||||
@@ -1734,12 +1728,12 @@ dependencies = [
|
|||||||
{ name = "sympy" },
|
{ name = "sympy" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/16/69ca742f0b65c40d4de3ff44bb6abc23c47b23e932bc901116176ae69922/onnxruntime_openvino-1.24.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3007c803634cc69c6d52af1dea7ce729d9bb62b9a11070fd2f959119199007a8", size = 84430935, upload-time = "2026-02-26T13:44:32.193Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/10/adcd4ac68ffc8dee003553125ef5c091be822e2d7c1077d0bb85690baa9c/onnxruntime_openvino-1.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:91938837e6e92e30c63d12fad68a8a4959c40d2eade2bd60f38bdd5b6392f8d3", size = 70481480, upload-time = "2025-10-14T15:19:45.882Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/73/619bb416bbfc40aebdd493fd6800d2637359294fe683d8a6bae3ff8d869a/onnxruntime_openvino-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:8042698232bf67f1f6b219c2b07728d7ae7ddff17d8524588de3675480609aef", size = 13655357, upload-time = "2026-02-26T13:44:35.555Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/95/25f28d6fecf300aa0af393e96af9e00cc676e5dab650ab84f2122610df50/onnxruntime_openvino-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:8f05d2d6a804fb70d3f4329d777ac62439773dcc2df827dd5f42644b10bf1fea", size = 13117353, upload-time = "2025-10-14T15:19:49.014Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/cf/17ba72de2df0fcba349937d2788f154397bbc2d1a2d67772a97e26f6bc5f/onnxruntime_openvino-1.24.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d617fac2f59a6ab5ea59a788c3e1592240a129642519aaeaa774761dfe35150e", size = 84433207, upload-time = "2026-02-26T13:44:41.395Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/0c/8d97419dfeedf419c5fe5293f3dbc59284855a63ad22e71f46c0010c9dc4/onnxruntime_openvino-1.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b963ea19bf9856f3d6b2f719d451f2eeae482a8f69c729906465aa4f27f4d39c", size = 70483359, upload-time = "2025-10-14T15:19:52.88Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/37/d301f2c68b19a9485ed5db3047e0fb52478f3e73eb08c7d2a7c61be7cc1c/onnxruntime_openvino-1.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:f186335a9c9b255633275290da7521d3d4d14c7773fee3127bfa040234d3fa5a", size = 13658075, upload-time = "2026-02-26T13:44:44.905Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/30/ff6111b16ffb4187c462824aa4e95acc20fdd90f856d44a339d56c6dacd6/onnxruntime_openvino-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:937e52657f94c56990a6e5bd4c3705bd6e970834c7c94e23d300dde6848f2889", size = 13117933, upload-time = "2025-10-14T15:19:58.319Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/07/f225999919f56506b603aaa3ff837ad563ab26f86906ed7fa7e5abcd849e/onnxruntime_openvino-1.24.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2c3bb73e68ac27f4891af8a595c1faf574ec68b772e6583c90a0b997a1822782", size = 84433183, upload-time = "2026-02-26T13:44:50.254Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/48/e42f618a8ec5fcf825fed4fdc8125f7105256cc6020b84567ecb88d5e2b7/onnxruntime_openvino-1.23.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2e93b9a8323e196b7433866054a59260f2206ab6fb0e7223dda91da71f1db8c5", size = 70483088, upload-time = "2025-10-14T15:20:02.425Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/92/46ae2cd565961a89189900f385bb2f13a9fa731ea4674001d23720fbb1e0/onnxruntime_openvino-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:434bf49aa71393c577a456c9d76c98e6d6958a833fa0876793e3d5437b5a511a", size = 13658485, upload-time = "2026-02-26T13:44:53.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/f9/a531dc497dc113dc14df9a9de5aacb1676cadebc3ec6cc7cd3ca65cb3db0/onnxruntime_openvino-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:0ebbf70929de4ce269371cb255536bbedef588932d744da0b40e66c38a620f35", size = 13118206, upload-time = "2025-10-14T15:20:05.587Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2179,6 +2173,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/39/92/8486ede85fcc088f1b3dba4ce92dd29d126fd96b0008ea213167940a2475/pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", size = 103139, upload-time = "2023-07-30T15:06:59.829Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/92/8486ede85fcc088f1b3dba4ce92dd29d126fd96b0008ea213167940a2475/pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", size = 103139, upload-time = "2023-07-30T15:06:59.829Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyreadline3"
|
||||||
|
version = "3.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/86/3d61a61f36a0067874a00cb4dceb9028d34b6060e47828f7fc86fb9f7ee9/pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae", size = 86465, upload-time = "2022-01-24T20:05:11.66Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/fc/a3c13ded7b3057680c8ae95a9b6cc83e63657c38e0005c400a5d018a33a7/pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb", size = 95203, upload-time = "2022-01-24T20:05:10.442Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.2"
|
version = "9.0.2"
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ config_roots = [
|
|||||||
[tools]
|
[tools]
|
||||||
node = "24.13.1"
|
node = "24.13.1"
|
||||||
flutter = "3.35.7"
|
flutter = "3.35.7"
|
||||||
pnpm = "10.30.3"
|
pnpm = "10.29.3"
|
||||||
terragrunt = "0.99.4"
|
terragrunt = "0.98.0"
|
||||||
opentofu = "1.11.5"
|
opentofu = "1.11.4"
|
||||||
java = "21.0.2"
|
java = "21.0.2"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"]
|
[tools."github:CQLabs/homebrew-dcm"]
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ linter:
|
|||||||
require_trailing_commas: true
|
require_trailing_commas: true
|
||||||
unrelated_type_equality_checks: true
|
unrelated_type_equality_checks: true
|
||||||
prefer_const_constructors: true
|
prefer_const_constructors: true
|
||||||
always_use_package_imports: true
|
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ android {
|
|||||||
|
|
||||||
release {
|
release {
|
||||||
signingConfig signingConfigs.release
|
signingConfig signingConfigs.release
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
namespace 'app.alextran.immich'
|
namespace 'app.alextran.immich'
|
||||||
|
|||||||
-8
@@ -37,11 +37,3 @@
|
|||||||
|
|
||||||
# Keep all widget model classes and their fields for Gson
|
# Keep all widget model classes and their fields for Gson
|
||||||
-keep class app.alextran.immich.widget.model.** { *; }
|
-keep class app.alextran.immich.widget.model.** { *; }
|
||||||
|
|
||||||
##---------------Begin: proguard configuration for ok_http JNI ----------
|
|
||||||
# The ok_http Dart plugin accesses OkHttp and Okio classes via JNI
|
|
||||||
# string-based reflection (JClass.forName), which R8 cannot trace.
|
|
||||||
-keep class okhttp3.** { *; }
|
|
||||||
-keep class okio.** { *; }
|
|
||||||
-keep class com.example.ok_http.** { *; }
|
|
||||||
##---------------End: proguard configuration for ok_http JNI ----------
|
|
||||||
|
|||||||
@@ -36,17 +36,3 @@ Java_app_alextran_immich_NativeBuffer_copy(
|
|||||||
memcpy((void *) destAddress, (char *) src + offset, length);
|
memcpy((void *) destAddress, (char *) src + offset, length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a JNI global reference to the given object and returns its address.
|
|
||||||
* The caller is responsible for deleting the global reference when it's no longer needed.
|
|
||||||
*/
|
|
||||||
JNIEXPORT jlong JNICALL
|
|
||||||
Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) {
|
|
||||||
if (obj == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
jobject globalRef = (*env)->NewGlobalRef(env, obj);
|
|
||||||
return (jlong) globalRef;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ object NativeBuffer {
|
|||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
|
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
external fun createGlobalRef(obj: Any): Long
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class NativeByteBuffer(initialCapacity: Int) {
|
class NativeByteBuffer(initialCapacity: Int) {
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
package app.alextran.immich.core
|
package app.alextran.immich.core
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.security.KeyChain
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import app.alextran.immich.BuildConfig
|
import app.alextran.immich.BuildConfig
|
||||||
import app.alextran.immich.NativeBuffer
|
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.ConnectionPool
|
import okhttp3.ConnectionPool
|
||||||
import okhttp3.Dispatcher
|
import okhttp3.Dispatcher
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.Credentials
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
@@ -27,12 +20,8 @@ import javax.net.ssl.TrustManagerFactory
|
|||||||
import javax.net.ssl.X509KeyManager
|
import javax.net.ssl.X509KeyManager
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
const val CERT_ALIAS = "client_cert"
|
||||||
const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
|
const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}"
|
||||||
private const val CERT_ALIAS = "client_cert"
|
|
||||||
private const val PREFS_NAME = "immich.ssl"
|
|
||||||
private const val PREFS_CERT_ALIAS = "immich.client_cert"
|
|
||||||
private const val PREFS_HEADERS = "immich.request_headers"
|
|
||||||
private const val PREFS_SERVER_URL = "immich.server_url"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages a shared OkHttpClient with SSL configuration support.
|
* Manages a shared OkHttpClient with SSL configuration support.
|
||||||
@@ -47,56 +36,22 @@ object HttpClientManager {
|
|||||||
private val clientChangedListeners = mutableListOf<() -> Unit>()
|
private val clientChangedListeners = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
private lateinit var client: OkHttpClient
|
private lateinit var client: OkHttpClient
|
||||||
private lateinit var appContext: Context
|
|
||||||
private lateinit var prefs: SharedPreferences
|
|
||||||
|
|
||||||
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
|
||||||
var keyChainAlias: String? = null
|
val isMtls: Boolean get() = keyStore.containsAlias(CERT_ALIAS)
|
||||||
private set
|
|
||||||
|
|
||||||
var headers: Headers = Headers.headersOf()
|
|
||||||
private set
|
|
||||||
|
|
||||||
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
|
|
||||||
|
|
||||||
fun initialize(context: Context) {
|
fun initialize(context: Context) {
|
||||||
if (initialized) return
|
if (initialized) return
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (initialized) return
|
if (initialized) return
|
||||||
|
|
||||||
appContext = context.applicationContext
|
|
||||||
prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
|
|
||||||
|
|
||||||
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
|
|
||||||
if (savedHeaders != null) {
|
|
||||||
val json = JSONObject(savedHeaders)
|
|
||||||
val builder = Headers.Builder()
|
|
||||||
for (key in json.keys()) {
|
|
||||||
builder.add(key, json.getString(key))
|
|
||||||
}
|
|
||||||
headers = builder.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
||||||
client = build(cacheDir)
|
client = build(cacheDir)
|
||||||
initialized = true
|
initialized = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setKeyChainAlias(alias: String) {
|
|
||||||
synchronized(this) {
|
|
||||||
val wasMtls = isMtls
|
|
||||||
keyChainAlias = alias
|
|
||||||
prefs.edit { putString(PREFS_CERT_ALIAS, alias) }
|
|
||||||
|
|
||||||
if (wasMtls != isMtls) {
|
|
||||||
clientChangedListeners.forEach { it() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setKeyEntry(clientData: ByteArray, password: CharArray) {
|
fun setKeyEntry(clientData: ByteArray, password: CharArray) {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
val wasMtls = isMtls
|
val wasMtls = isMtls
|
||||||
@@ -108,7 +63,7 @@ object HttpClientManager {
|
|||||||
val key = tmpKeyStore.getKey(tmpAlias, password)
|
val key = tmpKeyStore.getKey(tmpAlias, password)
|
||||||
val chain = tmpKeyStore.getCertificateChain(tmpAlias)
|
val chain = tmpKeyStore.getCertificateChain(tmpAlias)
|
||||||
|
|
||||||
if (keyStore.containsAlias(CERT_ALIAS)) {
|
if (wasMtls) {
|
||||||
keyStore.deleteEntry(CERT_ALIAS)
|
keyStore.deleteEntry(CERT_ALIAS)
|
||||||
}
|
}
|
||||||
keyStore.setKeyEntry(CERT_ALIAS, key, null, chain)
|
keyStore.setKeyEntry(CERT_ALIAS, key, null, chain)
|
||||||
@@ -120,58 +75,24 @@ object HttpClientManager {
|
|||||||
|
|
||||||
fun deleteKeyEntry() {
|
fun deleteKeyEntry() {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
val wasMtls = isMtls
|
if (!isMtls) {
|
||||||
|
return
|
||||||
if (keyChainAlias != null) {
|
|
||||||
keyChainAlias = null
|
|
||||||
prefs.edit { remove(PREFS_CERT_ALIAS) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
keyStore.deleteEntry(CERT_ALIAS)
|
keyStore.deleteEntry(CERT_ALIAS)
|
||||||
|
clientChangedListeners.forEach { it() }
|
||||||
if (wasMtls) {
|
|
||||||
clientChangedListeners.forEach { it() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var clientGlobalRef: Long = 0L
|
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getClient(): OkHttpClient {
|
fun getClient(): OkHttpClient {
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getClientPointer(): Long {
|
|
||||||
if (clientGlobalRef == 0L) {
|
|
||||||
clientGlobalRef = NativeBuffer.createGlobalRef(client)
|
|
||||||
}
|
|
||||||
return clientGlobalRef
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addClientChangedListener(listener: () -> Unit) {
|
fun addClientChangedListener(listener: () -> Unit) {
|
||||||
synchronized(this) { clientChangedListeners.add(listener) }
|
synchronized(this) { clientChangedListeners.add(listener) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>) {
|
|
||||||
synchronized(this) {
|
|
||||||
val builder = Headers.Builder()
|
|
||||||
headerMap.forEach { (key, value) -> builder[key] = value }
|
|
||||||
val newHeaders = builder.build()
|
|
||||||
val headersChanged = headers != newHeaders
|
|
||||||
val newUrl = serverUrls.firstOrNull()
|
|
||||||
val urlChanged = newUrl != prefs.getString(PREFS_SERVER_URL, null)
|
|
||||||
if (!headersChanged && !urlChanged) return
|
|
||||||
headers = newHeaders
|
|
||||||
prefs.edit {
|
|
||||||
if (headersChanged) putString(PREFS_HEADERS, JSONObject(headerMap).toString())
|
|
||||||
if (urlChanged) {
|
|
||||||
if (newUrl != null) putString(PREFS_SERVER_URL, newUrl) else remove(PREFS_SERVER_URL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun build(cacheDir: File): OkHttpClient {
|
private fun build(cacheDir: File): OkHttpClient {
|
||||||
val connectionPool = ConnectionPool(
|
val connectionPool = ConnectionPool(
|
||||||
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
||||||
@@ -188,16 +109,8 @@ object HttpClientManager {
|
|||||||
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||||
|
|
||||||
return OkHttpClient.Builder()
|
return OkHttpClient.Builder()
|
||||||
.addInterceptor {
|
.addInterceptor { chain ->
|
||||||
val request = it.request()
|
chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build())
|
||||||
val builder = request.newBuilder()
|
|
||||||
builder.header("User-Agent", USER_AGENT)
|
|
||||||
headers.forEach { (key, value) -> builder.header(key, value) }
|
|
||||||
val url = request.url
|
|
||||||
if (url.username.isNotEmpty()) {
|
|
||||||
builder.header("Authorization", Credentials.basic(url.username, url.password))
|
|
||||||
}
|
|
||||||
it.proceed(builder.build())
|
|
||||||
}
|
}
|
||||||
.connectionPool(connectionPool)
|
.connectionPool(connectionPool)
|
||||||
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
|
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
|
||||||
@@ -206,39 +119,23 @@ object HttpClientManager {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Reads from the key store rather than taking a snapshot at initialization time
|
||||||
* Resolves client certificates dynamically at TLS handshake time.
|
|
||||||
* Checks the system KeyChain alias first, then falls back to the app's private KeyStore.
|
|
||||||
*/
|
|
||||||
private class DynamicKeyManager : X509KeyManager {
|
private class DynamicKeyManager : X509KeyManager {
|
||||||
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? {
|
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
|
||||||
val alias = chooseClientAlias(arrayOf(keyType), issuers, null) ?: return null
|
if (isMtls) arrayOf(CERT_ALIAS) else null
|
||||||
return arrayOf(alias)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun chooseClientAlias(
|
override fun chooseClientAlias(
|
||||||
keyTypes: Array<String>,
|
keyTypes: Array<String>,
|
||||||
issuers: Array<Principal>?,
|
issuers: Array<Principal>?,
|
||||||
socket: Socket?
|
socket: Socket?
|
||||||
): String? {
|
): String? =
|
||||||
keyChainAlias?.let { return it }
|
if (isMtls) CERT_ALIAS else null
|
||||||
if (keyStore.containsAlias(CERT_ALIAS)) return CERT_ALIAS
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCertificateChain(alias: String): Array<X509Certificate>? {
|
override fun getCertificateChain(alias: String): Array<X509Certificate>? =
|
||||||
if (alias == keyChainAlias) {
|
keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
|
||||||
return KeyChain.getCertificateChain(appContext, alias)
|
|
||||||
}
|
|
||||||
return keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPrivateKey(alias: String): PrivateKey? {
|
override fun getPrivateKey(alias: String): PrivateKey? =
|
||||||
if (alias == keyChainAlias) {
|
keyStore.getKey(alias, null) as? PrivateKey
|
||||||
return KeyChain.getPrivateKey(appContext, alias)
|
|
||||||
}
|
|
||||||
return keyStore.getKey(alias, null) as? PrivateKey
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
|
override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -180,11 +180,8 @@ private open class NetworkPigeonCodec : StandardMessageCodec() {
|
|||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface NetworkApi {
|
interface NetworkApi {
|
||||||
fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit)
|
fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit)
|
||||||
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit)
|
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit)
|
||||||
fun removeCertificate(callback: (Result<Unit>) -> Unit)
|
fun removeCertificate(callback: (Result<Unit>) -> Unit)
|
||||||
fun hasCertificate(): Boolean
|
|
||||||
fun getClientPointer(): Long
|
|
||||||
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by NetworkApi. */
|
/** The codec used by NetworkApi. */
|
||||||
@@ -220,12 +217,13 @@ interface NetworkApi {
|
|||||||
channel.setMessageHandler { message, reply ->
|
channel.setMessageHandler { message, reply ->
|
||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val promptTextArg = args[0] as ClientCertPrompt
|
val promptTextArg = args[0] as ClientCertPrompt
|
||||||
api.selectCertificate(promptTextArg) { result: Result<Unit> ->
|
api.selectCertificate(promptTextArg) { result: Result<ClientCertData> ->
|
||||||
val error = result.exceptionOrNull()
|
val error = result.exceptionOrNull()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
reply.reply(NetworkPigeonUtils.wrapError(error))
|
reply.reply(NetworkPigeonUtils.wrapError(error))
|
||||||
} else {
|
} else {
|
||||||
reply.reply(NetworkPigeonUtils.wrapResult(null))
|
val data = result.getOrNull()
|
||||||
|
reply.reply(NetworkPigeonUtils.wrapResult(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,55 +248,6 @@ interface NetworkApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
run {
|
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$separatedMessageChannelSuffix", codec)
|
|
||||||
if (api != null) {
|
|
||||||
channel.setMessageHandler { _, reply ->
|
|
||||||
val wrapped: List<Any?> = try {
|
|
||||||
listOf(api.hasCertificate())
|
|
||||||
} catch (exception: Throwable) {
|
|
||||||
NetworkPigeonUtils.wrapError(exception)
|
|
||||||
}
|
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
channel.setMessageHandler(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run {
|
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$separatedMessageChannelSuffix", codec)
|
|
||||||
if (api != null) {
|
|
||||||
channel.setMessageHandler { _, reply ->
|
|
||||||
val wrapped: List<Any?> = try {
|
|
||||||
listOf(api.getClientPointer())
|
|
||||||
} catch (exception: Throwable) {
|
|
||||||
NetworkPigeonUtils.wrapError(exception)
|
|
||||||
}
|
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
channel.setMessageHandler(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run {
|
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$separatedMessageChannelSuffix", codec)
|
|
||||||
if (api != null) {
|
|
||||||
channel.setMessageHandler { message, reply ->
|
|
||||||
val args = message as List<Any?>
|
|
||||||
val headersArg = args[0] as Map<String, String>
|
|
||||||
val serverUrlsArg = args[1] as List<String>
|
|
||||||
val wrapped: List<Any?> = try {
|
|
||||||
api.setRequestHeaders(headersArg, serverUrlsArg)
|
|
||||||
listOf(null)
|
|
||||||
} catch (exception: Throwable) {
|
|
||||||
NetworkPigeonUtils.wrapError(exception)
|
|
||||||
}
|
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
channel.setMessageHandler(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,20 @@ package app.alextran.immich.core
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
import android.security.KeyChain
|
import android.text.InputType
|
||||||
import app.alextran.immich.NativeBuffer
|
import android.view.ContextThemeWrapper
|
||||||
|
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.textfield.TextInputEditText
|
||||||
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
@@ -13,7 +24,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
|
|||||||
private var networkApi: NetworkApiImpl? = null
|
private var networkApi: NetworkApiImpl? = null
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
networkApi = NetworkApiImpl()
|
networkApi = NetworkApiImpl(binding.applicationContext)
|
||||||
NetworkApi.setUp(binding.binaryMessenger, networkApi)
|
NetworkApi.setUp(binding.binaryMessenger, networkApi)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,24 +34,48 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
networkApi?.activity = binding.activity
|
networkApi?.onAttachedToActivity(binding)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromActivityForConfigChanges() {
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
networkApi?.activity = null
|
networkApi?.onDetachedFromActivityForConfigChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||||
networkApi?.activity = binding.activity
|
networkApi?.onReattachedToActivityForConfigChanges(binding)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromActivity() {
|
override fun onDetachedFromActivity() {
|
||||||
networkApi?.activity = null
|
networkApi?.onDetachedFromActivity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NetworkApiImpl() : NetworkApi {
|
private class NetworkApiImpl(private val context: Context) : NetworkApi {
|
||||||
var activity: Activity? = null
|
private var activity: Activity? = null
|
||||||
|
private var pendingCallback: ((Result<ClientCertData>) -> Unit)? = null
|
||||||
|
private var filePicker: ActivityResultLauncher<Array<String>>? = null
|
||||||
|
private var promptText: ClientCertPrompt? = null
|
||||||
|
|
||||||
|
fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
activity = binding.activity
|
||||||
|
(binding.activity as? ComponentActivity)?.let { componentActivity ->
|
||||||
|
filePicker = componentActivity.registerForActivityResult(
|
||||||
|
ActivityResultContracts.OpenDocument()
|
||||||
|
) { uri -> uri?.let { handlePickedFile(it) } ?: pendingCallback?.invoke(Result.failure(OperationCanceledException())) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDetachedFromActivityForConfigChanges() {
|
||||||
|
activity = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||||
|
activity = binding.activity
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDetachedFromActivity() {
|
||||||
|
activity = null
|
||||||
|
}
|
||||||
|
|
||||||
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
|
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
|
||||||
try {
|
try {
|
||||||
@@ -51,19 +86,11 @@ private class NetworkApiImpl() : NetworkApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit) {
|
override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit) {
|
||||||
val currentActivity = activity
|
val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity")))
|
||||||
?: return callback(Result.failure(IllegalStateException("No activity")))
|
pendingCallback = callback
|
||||||
|
this.promptText = promptText
|
||||||
val onAlias = { alias: String? ->
|
picker.launch(arrayOf("application/x-pkcs12", "application/x-pem-file"))
|
||||||
if (alias != null) {
|
|
||||||
HttpClientManager.setKeyChainAlias(alias)
|
|
||||||
callback(Result.success(Unit))
|
|
||||||
} else {
|
|
||||||
callback(Result.failure(OperationCanceledException()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyChain.choosePrivateKeyAlias(currentActivity, onAlias, null, null, null, null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun removeCertificate(callback: (Result<Unit>) -> Unit) {
|
override fun removeCertificate(callback: (Result<Unit>) -> Unit) {
|
||||||
@@ -71,15 +98,62 @@ private class NetworkApiImpl() : NetworkApi {
|
|||||||
callback(Result.success(Unit))
|
callback(Result.success(Unit))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hasCertificate(): Boolean {
|
private fun handlePickedFile(uri: Uri) {
|
||||||
return HttpClientManager.isMtls
|
val callback = pendingCallback ?: return
|
||||||
|
pendingCallback = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
val data = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
?: throw IllegalStateException("Could not read file")
|
||||||
|
|
||||||
|
val activity = activity ?: throw IllegalStateException("No activity")
|
||||||
|
promptForPassword(activity) { password ->
|
||||||
|
promptText = null
|
||||||
|
if (password == null) {
|
||||||
|
callback(Result.failure(OperationCanceledException()))
|
||||||
|
return@promptForPassword
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
HttpClientManager.setKeyEntry(data, password.toCharArray())
|
||||||
|
callback(Result.success(ClientCertData(data, password)))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
callback(Result.failure(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
callback(Result.failure(e))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getClientPointer(): Long {
|
private fun promptForPassword(activity: Activity, callback: (String?) -> Unit) {
|
||||||
return HttpClientManager.getClientPointer()
|
val themedContext = ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight_Dialog)
|
||||||
}
|
val density = activity.resources.displayMetrics.density
|
||||||
|
val horizontalPadding = (24 * density).toInt()
|
||||||
|
|
||||||
override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>) {
|
val textInputLayout = TextInputLayout(themedContext).apply {
|
||||||
HttpClientManager.setRequestHeaders(headers, serverUrls)
|
hint = "Password"
|
||||||
|
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
|
||||||
|
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
|
||||||
|
setMargins(horizontalPadding, 0, horizontalPadding, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val editText = TextInputEditText(textInputLayout.context).apply {
|
||||||
|
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||||
|
}
|
||||||
|
textInputLayout.addView(editText)
|
||||||
|
|
||||||
|
val container = FrameLayout(themedContext).apply { addView(textInputLayout) }
|
||||||
|
|
||||||
|
val text = promptText!!
|
||||||
|
MaterialAlertDialogBuilder(themedContext)
|
||||||
|
.setTitle(text.title)
|
||||||
|
.setMessage(text.message)
|
||||||
|
.setView(container)
|
||||||
|
.setPositiveButton(text.confirm) { _, _ -> callback(editText.text.toString()) }
|
||||||
|
.setNegativeButton(text.cancel) { _, _ -> callback(null) }
|
||||||
|
.setOnCancelListener { callback(null) }
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ private open class LocalImagesPigeonCodec : StandardMessageCodec() {
|
|||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface LocalImageApi {
|
interface LocalImageApi {
|
||||||
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
||||||
fun cancelRequest(requestId: Long)
|
fun cancelRequest(requestId: Long)
|
||||||
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
|
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
|
||||||
|
|
||||||
@@ -82,8 +82,7 @@ interface LocalImageApi {
|
|||||||
val widthArg = args[2] as Long
|
val widthArg = args[2] as Long
|
||||||
val heightArg = args[3] as Long
|
val heightArg = args[3] as Long
|
||||||
val isVideoArg = args[4] as Boolean
|
val isVideoArg = args[4] as Boolean
|
||||||
val preferEncodedArg = args[5] as Boolean
|
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>?> ->
|
||||||
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
|
|
||||||
val error = result.exceptionOrNull()
|
val error = result.exceptionOrNull()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
reply.reply(LocalImagesPigeonUtils.wrapError(error))
|
reply.reply(LocalImagesPigeonUtils.wrapError(error))
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import android.util.Size
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import app.alextran.immich.NativeBuffer
|
import app.alextran.immich.NativeBuffer
|
||||||
import kotlin.math.*
|
import kotlin.math.*
|
||||||
import java.io.IOException
|
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.Priority
|
import com.bumptech.glide.Priority
|
||||||
@@ -49,6 +48,7 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
|||||||
try {
|
try {
|
||||||
val buffer = NativeBuffer.wrap(pointer, size)
|
val buffer = NativeBuffer.wrap(pointer, size)
|
||||||
copyPixelsToBuffer(buffer)
|
copyPixelsToBuffer(buffer)
|
||||||
|
recycle()
|
||||||
return mapOf(
|
return mapOf(
|
||||||
"pointer" to pointer,
|
"pointer" to pointer,
|
||||||
"width" to width.toLong(),
|
"width" to width.toLong(),
|
||||||
@@ -57,9 +57,8 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
|||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
NativeBuffer.free(pointer)
|
NativeBuffer.free(pointer)
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
recycle()
|
recycle()
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,17 +99,12 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
|||||||
width: Long,
|
width: Long,
|
||||||
height: Long,
|
height: Long,
|
||||||
isVideo: Boolean,
|
isVideo: Boolean,
|
||||||
preferEncoded: Boolean,
|
|
||||||
callback: (Result<Map<String, Long>?>) -> Unit
|
callback: (Result<Map<String, Long>?>) -> Unit
|
||||||
) {
|
) {
|
||||||
val signal = CancellationSignal()
|
val signal = CancellationSignal()
|
||||||
val task = threadPool.submit {
|
val task = threadPool.submit {
|
||||||
try {
|
try {
|
||||||
if (preferEncoded) {
|
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
|
||||||
getEncodedImageInternal(assetId, callback, signal)
|
|
||||||
} else {
|
|
||||||
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
when (e) {
|
when (e) {
|
||||||
is OperationCanceledException -> callback(CANCELLED)
|
is OperationCanceledException -> callback(CANCELLED)
|
||||||
@@ -139,35 +133,6 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getEncodedImageInternal(
|
|
||||||
assetId: String,
|
|
||||||
callback: (Result<Map<String, Long>?>) -> Unit,
|
|
||||||
signal: CancellationSignal
|
|
||||||
) {
|
|
||||||
signal.throwIfCanceled()
|
|
||||||
val id = assetId.toLong()
|
|
||||||
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
|
|
||||||
|
|
||||||
signal.throwIfCanceled()
|
|
||||||
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
|
|
||||||
?: throw IOException("Could not read image data for $assetId")
|
|
||||||
|
|
||||||
signal.throwIfCanceled()
|
|
||||||
val pointer = NativeBuffer.allocate(bytes.size)
|
|
||||||
try {
|
|
||||||
val buffer = NativeBuffer.wrap(pointer, bytes.size)
|
|
||||||
buffer.put(bytes)
|
|
||||||
signal.throwIfCanceled()
|
|
||||||
callback(Result.success(mapOf(
|
|
||||||
"pointer" to pointer,
|
|
||||||
"length" to bytes.size.toLong()
|
|
||||||
)))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
NativeBuffer.free(pointer)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getThumbnailBufferInternal(
|
private fun getThumbnailBufferInternal(
|
||||||
assetId: String,
|
assetId: String,
|
||||||
width: Long,
|
width: Long,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
|
|||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface RemoteImageApi {
|
interface RemoteImageApi {
|
||||||
fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, callback: (Result<Map<String, Long>?>) -> Unit)
|
||||||
fun cancelRequest(requestId: Long)
|
fun cancelRequest(requestId: Long)
|
||||||
fun clearCache(callback: (Result<Long>) -> Unit)
|
fun clearCache(callback: (Result<Long>) -> Unit)
|
||||||
|
|
||||||
@@ -66,9 +66,9 @@ interface RemoteImageApi {
|
|||||||
channel.setMessageHandler { message, reply ->
|
channel.setMessageHandler { message, reply ->
|
||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val urlArg = args[0] as String
|
val urlArg = args[0] as String
|
||||||
val requestIdArg = args[1] as Long
|
val headersArg = args[1] as Map<String, String>
|
||||||
val preferEncodedArg = args[2] as Boolean
|
val requestIdArg = args[2] as Long
|
||||||
api.requestImage(urlArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
|
api.requestImage(urlArg, headersArg, requestIdArg) { result: Result<Map<String, Long>?> ->
|
||||||
val error = result.exceptionOrNull()
|
val error = result.exceptionOrNull()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
|
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import okhttp3.Callback
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.Credentials
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import org.chromium.net.CronetEngine
|
import org.chromium.net.CronetEngine
|
||||||
import org.chromium.net.CronetException
|
import org.chromium.net.CronetException
|
||||||
import org.chromium.net.UrlRequest
|
import org.chromium.net.UrlRequest
|
||||||
@@ -51,8 +49,8 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
|
|
||||||
override fun requestImage(
|
override fun requestImage(
|
||||||
url: String,
|
url: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
requestId: Long,
|
requestId: Long,
|
||||||
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
|
||||||
callback: (Result<Map<String, Long>?>) -> Unit
|
callback: (Result<Map<String, Long>?>) -> Unit
|
||||||
) {
|
) {
|
||||||
val signal = CancellationSignal()
|
val signal = CancellationSignal()
|
||||||
@@ -60,6 +58,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
|
|
||||||
ImageFetcherManager.fetch(
|
ImageFetcherManager.fetch(
|
||||||
url,
|
url,
|
||||||
|
headers,
|
||||||
signal,
|
signal,
|
||||||
onSuccess = { buffer ->
|
onSuccess = { buffer ->
|
||||||
requestMap.remove(requestId)
|
requestMap.remove(requestId)
|
||||||
@@ -120,11 +119,12 @@ private object ImageFetcherManager {
|
|||||||
|
|
||||||
fun fetch(
|
fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
signal: CancellationSignal,
|
signal: CancellationSignal,
|
||||||
onSuccess: (NativeByteBuffer) -> Unit,
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
onFailure: (Exception) -> Unit,
|
onFailure: (Exception) -> Unit,
|
||||||
) {
|
) {
|
||||||
fetcher.fetch(url, signal, onSuccess, onFailure)
|
fetcher.fetch(url, headers, signal, onSuccess, onFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearCache(onCleared: (Result<Long>) -> Unit) {
|
fun clearCache(onCleared: (Result<Long>) -> Unit) {
|
||||||
@@ -151,6 +151,7 @@ private object ImageFetcherManager {
|
|||||||
private sealed interface ImageFetcher {
|
private sealed interface ImageFetcher {
|
||||||
fun fetch(
|
fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
signal: CancellationSignal,
|
signal: CancellationSignal,
|
||||||
onSuccess: (NativeByteBuffer) -> Unit,
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
onFailure: (Exception) -> Unit,
|
onFailure: (Exception) -> Unit,
|
||||||
@@ -177,6 +178,7 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
|
|||||||
|
|
||||||
override fun fetch(
|
override fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
signal: CancellationSignal,
|
signal: CancellationSignal,
|
||||||
onSuccess: (NativeByteBuffer) -> Unit,
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
onFailure: (Exception) -> Unit,
|
onFailure: (Exception) -> Unit,
|
||||||
@@ -191,12 +193,7 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
|
|||||||
|
|
||||||
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
||||||
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
|
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
|
||||||
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
||||||
url.toHttpUrlOrNull()?.let { httpUrl ->
|
|
||||||
if (httpUrl.username.isNotEmpty()) {
|
|
||||||
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val request = requestBuilder.build()
|
val request = requestBuilder.build()
|
||||||
signal.setOnCancelListener(request::cancel)
|
signal.setOnCancelListener(request::cancel)
|
||||||
request.start()
|
request.start()
|
||||||
@@ -393,6 +390,7 @@ private class OkHttpImageFetcher private constructor(
|
|||||||
|
|
||||||
override fun fetch(
|
override fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
|
headers: Map<String, String>,
|
||||||
signal: CancellationSignal,
|
signal: CancellationSignal,
|
||||||
onSuccess: (NativeByteBuffer) -> Unit,
|
onSuccess: (NativeByteBuffer) -> Unit,
|
||||||
onFailure: (Exception) -> Unit,
|
onFailure: (Exception) -> Unit,
|
||||||
@@ -405,6 +403,7 @@ private class OkHttpImageFetcher private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val requestBuilder = Request.Builder().url(url)
|
val requestBuilder = Request.Builder().url(url)
|
||||||
|
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
||||||
val call = client.newCall(requestBuilder.build())
|
val call = client.newCall(requestBuilder.build())
|
||||||
signal.setOnCancelListener(call::cancel)
|
signal.setOnCancelListener(call::cancel)
|
||||||
|
|
||||||
|
|||||||
@@ -78,21 +78,6 @@ class FlutterError (
|
|||||||
val details: Any? = null
|
val details: Any? = null
|
||||||
) : Throwable()
|
) : Throwable()
|
||||||
|
|
||||||
enum class PlatformAssetPlaybackStyle(val raw: Int) {
|
|
||||||
UNKNOWN(0),
|
|
||||||
IMAGE(1),
|
|
||||||
VIDEO(2),
|
|
||||||
IMAGE_ANIMATED(3),
|
|
||||||
LIVE_PHOTO(4),
|
|
||||||
VIDEO_LOOPING(5);
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun ofRaw(raw: Int): PlatformAssetPlaybackStyle? {
|
|
||||||
return values().firstOrNull { it.raw == raw }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generated class from Pigeon that represents data sent in messages. */
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
data class PlatformAsset (
|
data class PlatformAsset (
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -107,8 +92,7 @@ data class PlatformAsset (
|
|||||||
val isFavorite: Boolean,
|
val isFavorite: Boolean,
|
||||||
val adjustmentTime: Long? = null,
|
val adjustmentTime: Long? = null,
|
||||||
val latitude: Double? = null,
|
val latitude: Double? = null,
|
||||||
val longitude: Double? = null,
|
val longitude: Double? = null
|
||||||
val playbackStyle: PlatformAssetPlaybackStyle
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
companion object {
|
companion object {
|
||||||
@@ -126,8 +110,7 @@ data class PlatformAsset (
|
|||||||
val adjustmentTime = pigeonVar_list[10] as Long?
|
val adjustmentTime = pigeonVar_list[10] as Long?
|
||||||
val latitude = pigeonVar_list[11] as Double?
|
val latitude = pigeonVar_list[11] as Double?
|
||||||
val longitude = pigeonVar_list[12] as Double?
|
val longitude = pigeonVar_list[12] as Double?
|
||||||
val playbackStyle = pigeonVar_list[13] as PlatformAssetPlaybackStyle
|
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude)
|
||||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude, playbackStyle)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun toList(): List<Any?> {
|
fun toList(): List<Any?> {
|
||||||
@@ -145,7 +128,6 @@ data class PlatformAsset (
|
|||||||
adjustmentTime,
|
adjustmentTime,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
playbackStyle,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
@@ -308,31 +290,26 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
return when (type) {
|
return when (type) {
|
||||||
129.toByte() -> {
|
129.toByte() -> {
|
||||||
return (readValue(buffer) as Long?)?.let {
|
|
||||||
PlatformAssetPlaybackStyle.ofRaw(it.toInt())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
130.toByte() -> {
|
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
PlatformAsset.fromList(it)
|
PlatformAsset.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
131.toByte() -> {
|
130.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
PlatformAlbum.fromList(it)
|
PlatformAlbum.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
132.toByte() -> {
|
131.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
SyncDelta.fromList(it)
|
SyncDelta.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
133.toByte() -> {
|
132.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
HashResult.fromList(it)
|
HashResult.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
134.toByte() -> {
|
133.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
CloudIdResult.fromList(it)
|
CloudIdResult.fromList(it)
|
||||||
}
|
}
|
||||||
@@ -342,28 +319,24 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
}
|
}
|
||||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||||
when (value) {
|
when (value) {
|
||||||
is PlatformAssetPlaybackStyle -> {
|
|
||||||
stream.write(129)
|
|
||||||
writeValue(stream, value.raw)
|
|
||||||
}
|
|
||||||
is PlatformAsset -> {
|
is PlatformAsset -> {
|
||||||
stream.write(130)
|
stream.write(129)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is PlatformAlbum -> {
|
is PlatformAlbum -> {
|
||||||
stream.write(131)
|
stream.write(130)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is SyncDelta -> {
|
is SyncDelta -> {
|
||||||
stream.write(132)
|
stream.write(131)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is HashResult -> {
|
is HashResult -> {
|
||||||
stream.write(133)
|
stream.write(132)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is CloudIdResult -> {
|
is CloudIdResult -> {
|
||||||
stream.write(134)
|
stream.write(133)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
else -> super.writeValue(stream, value)
|
else -> super.writeValue(stream, value)
|
||||||
|
|||||||
@@ -4,18 +4,11 @@ import android.annotation.SuppressLint
|
|||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import androidx.exifinterface.media.ExifInterface
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.ext.SdkExtensions
|
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
import app.alextran.immich.core.ImmichPlugin
|
import app.alextran.immich.core.ImmichPlugin
|
||||||
import com.bumptech.glide.Glide
|
|
||||||
import com.bumptech.glide.load.ImageHeaderParser
|
|
||||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -35,8 +28,6 @@ sealed class AssetResult {
|
|||||||
data class InvalidAsset(val assetId: String) : AssetResult()
|
data class InvalidAsset(val assetId: String) : AssetResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val TAG = "NativeSyncApiImplBase"
|
|
||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
@SuppressLint("InlinedApi")
|
||||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
@@ -48,13 +39,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
|
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
|
||||||
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
|
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
|
||||||
|
|
||||||
// MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+
|
|
||||||
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
|
|
||||||
private const val SPECIAL_FORMAT_COLUMN = "_special_format"
|
|
||||||
private const val SPECIAL_FORMAT_GIF = 1
|
|
||||||
private const val SPECIAL_FORMAT_MOTION_PHOTO = 2
|
|
||||||
private const val SPECIAL_FORMAT_ANIMATED_WEBP = 3
|
|
||||||
|
|
||||||
const val MEDIA_SELECTION =
|
const val MEDIA_SELECTION =
|
||||||
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
||||||
val MEDIA_SELECTION_ARGS = arrayOf(
|
val MEDIA_SELECTION_ARGS = arrayOf(
|
||||||
@@ -76,25 +60,12 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
add(MediaStore.MediaColumns.DURATION)
|
add(MediaStore.MediaColumns.DURATION)
|
||||||
add(MediaStore.MediaColumns.ORIENTATION)
|
add(MediaStore.MediaColumns.ORIENTATION)
|
||||||
// IS_FAVORITE is only available on Android 11 and above
|
// IS_FAVORITE is only available on Android 11 and above
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||||
add(MediaStore.MediaColumns.IS_FAVORITE)
|
add(MediaStore.MediaColumns.IS_FAVORITE)
|
||||||
}
|
}
|
||||||
if (hasSpecialFormatColumn()) {
|
|
||||||
add(SPECIAL_FORMAT_COLUMN)
|
|
||||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
// Fallback: read XMP from MediaStore to detect Motion Photos
|
|
||||||
// only needed if SPECIAL_FORMAT column isn't available
|
|
||||||
add(MediaStore.MediaColumns.XMP)
|
|
||||||
}
|
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
|
|
||||||
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
||||||
|
|
||||||
// _special_format requires S Extensions 21+
|
|
||||||
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
|
|
||||||
private fun hasSpecialFormatColumn(): Boolean =
|
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
|
||||||
SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 21
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun getCursor(
|
protected fun getCursor(
|
||||||
@@ -138,12 +109,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
val orientationColumn =
|
val orientationColumn =
|
||||||
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
|
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
|
||||||
val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE)
|
val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE)
|
||||||
val specialFormatColumn = c.getColumnIndex(SPECIAL_FORMAT_COLUMN)
|
|
||||||
val xmpColumn = c.getColumnIndex(MediaStore.MediaColumns.XMP)
|
|
||||||
|
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
val numericId = c.getLong(idColumn)
|
val id = c.getLong(idColumn).toString()
|
||||||
val id = numericId.toString()
|
|
||||||
val name = c.getStringOrNull(nameColumn)
|
val name = c.getStringOrNull(nameColumn)
|
||||||
val bucketId = c.getStringOrNull(bucketIdColumn)
|
val bucketId = c.getStringOrNull(bucketIdColumn)
|
||||||
val path = c.getStringOrNull(dataColumn)
|
val path = c.getStringOrNull(dataColumn)
|
||||||
@@ -157,11 +125,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
val rawMediaType = c.getInt(mediaTypeColumn)
|
val mediaType = when (c.getInt(mediaTypeColumn)) {
|
||||||
val assetType: Long = when (rawMediaType) {
|
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1
|
||||||
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1L
|
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2
|
||||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L
|
else -> 0
|
||||||
else -> 0L
|
|
||||||
}
|
}
|
||||||
// Date taken is milliseconds since epoch, Date added is seconds since epoch
|
// Date taken is milliseconds since epoch, Date added is seconds since epoch
|
||||||
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
|
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
|
||||||
@@ -171,19 +138,15 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
val width = c.getInt(widthColumn).toLong()
|
val width = c.getInt(widthColumn).toLong()
|
||||||
val height = c.getInt(heightColumn).toLong()
|
val height = c.getInt(heightColumn).toLong()
|
||||||
// Duration is milliseconds
|
// Duration is milliseconds
|
||||||
val duration = if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0L
|
val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
|
||||||
else c.getLong(durationColumn) / 1000
|
else c.getLong(durationColumn) / 1000
|
||||||
val orientation = c.getInt(orientationColumn)
|
val orientation = c.getInt(orientationColumn)
|
||||||
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
|
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
|
||||||
|
|
||||||
val playbackStyle = detectPlaybackStyle(
|
|
||||||
numericId, rawMediaType, specialFormatColumn, xmpColumn, c
|
|
||||||
)
|
|
||||||
|
|
||||||
val asset = PlatformAsset(
|
val asset = PlatformAsset(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
assetType,
|
mediaType.toLong(),
|
||||||
createdAt,
|
createdAt,
|
||||||
modifiedAt,
|
modifiedAt,
|
||||||
width,
|
width,
|
||||||
@@ -191,7 +154,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
duration,
|
duration,
|
||||||
orientation.toLong(),
|
orientation.toLong(),
|
||||||
isFavorite,
|
isFavorite,
|
||||||
playbackStyle = playbackStyle,
|
|
||||||
)
|
)
|
||||||
yield(AssetResult.ValidAsset(asset, bucketId))
|
yield(AssetResult.ValidAsset(asset, bucketId))
|
||||||
}
|
}
|
||||||
@@ -199,81 +161,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects the playback style for an asset using _special_format (API 33+)
|
|
||||||
* or XMP / MIME / RIFF header fallbacks (pre-33).
|
|
||||||
*/
|
|
||||||
@SuppressLint("NewApi")
|
|
||||||
private fun detectPlaybackStyle(
|
|
||||||
assetId: Long,
|
|
||||||
rawMediaType: Int,
|
|
||||||
specialFormatColumn: Int,
|
|
||||||
xmpColumn: Int,
|
|
||||||
cursor: Cursor
|
|
||||||
): PlatformAssetPlaybackStyle {
|
|
||||||
// video currently has no special formats, so we can short circuit and avoid unnecessary work
|
|
||||||
if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) {
|
|
||||||
return PlatformAssetPlaybackStyle.VIDEO
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 33+: use _special_format from cursor
|
|
||||||
if (specialFormatColumn != -1) {
|
|
||||||
val specialFormat = cursor.getInt(specialFormatColumn)
|
|
||||||
return when {
|
|
||||||
specialFormat == SPECIAL_FORMAT_MOTION_PHOTO -> PlatformAssetPlaybackStyle.LIVE_PHOTO
|
|
||||||
specialFormat == SPECIAL_FORMAT_GIF || specialFormat == SPECIAL_FORMAT_ANIMATED_WEBP -> PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
|
||||||
rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> PlatformAssetPlaybackStyle.IMAGE
|
|
||||||
else -> PlatformAssetPlaybackStyle.UNKNOWN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rawMediaType != MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) {
|
|
||||||
return PlatformAssetPlaybackStyle.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-API 33 fallback
|
|
||||||
val uri = ContentUris.withAppendedId(
|
|
||||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
|
||||||
assetId
|
|
||||||
)
|
|
||||||
|
|
||||||
// Read XMP from cursor (API 30+) or ExifInterface stream (pre-30)
|
|
||||||
val xmp: String? = if (xmpColumn != -1) {
|
|
||||||
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
|
||||||
ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xmp != null && "Camera:MotionPhoto" in xmp) {
|
|
||||||
return PlatformAssetPlaybackStyle.LIVE_PHOTO
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
|
||||||
val glide = Glide.get(ctx)
|
|
||||||
val type = ImageHeaderParserUtils.getType(
|
|
||||||
glide.registry.imageHeaderParsers,
|
|
||||||
stream,
|
|
||||||
glide.arrayPool
|
|
||||||
)
|
|
||||||
if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) {
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAlbums(): List<PlatformAlbum> {
|
fun getAlbums(): List<PlatformAlbum> {
|
||||||
val albums = mutableListOf<PlatformAlbum>()
|
val albums = mutableListOf<PlatformAlbum>()
|
||||||
val albumsCount = mutableMapOf<String, Int>()
|
val albumsCount = mutableMapOf<String, Int>()
|
||||||
|
|||||||
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
@@ -221,11 +221,8 @@ class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
|||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol NetworkApi {
|
protocol NetworkApi {
|
||||||
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void)
|
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, Error>) -> Void)
|
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, Error>) -> Void)
|
||||||
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
|
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
func hasCertificate() throws -> Bool
|
|
||||||
func getClientPointer() throws -> Int64
|
|
||||||
func setRequestHeaders(headers: [String: String], serverUrls: [String]) throws
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -258,8 +255,8 @@ class NetworkApiSetup {
|
|||||||
let promptTextArg = args[0] as! ClientCertPrompt
|
let promptTextArg = args[0] as! ClientCertPrompt
|
||||||
api.selectCertificate(promptText: promptTextArg) { result in
|
api.selectCertificate(promptText: promptTextArg) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success:
|
case .success(let res):
|
||||||
reply(wrapResult(nil))
|
reply(wrapResult(res))
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
reply(wrapError(error))
|
reply(wrapError(error))
|
||||||
}
|
}
|
||||||
@@ -283,47 +280,5 @@ class NetworkApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
removeCertificateChannel.setMessageHandler(nil)
|
removeCertificateChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
let hasCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
|
||||||
if let api = api {
|
|
||||||
hasCertificateChannel.setMessageHandler { _, reply in
|
|
||||||
do {
|
|
||||||
let result = try api.hasCertificate()
|
|
||||||
reply(wrapResult(result))
|
|
||||||
} catch {
|
|
||||||
reply(wrapError(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hasCertificateChannel.setMessageHandler(nil)
|
|
||||||
}
|
|
||||||
let getClientPointerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
|
||||||
if let api = api {
|
|
||||||
getClientPointerChannel.setMessageHandler { _, reply in
|
|
||||||
do {
|
|
||||||
let result = try api.getClientPointer()
|
|
||||||
reply(wrapResult(result))
|
|
||||||
} catch {
|
|
||||||
reply(wrapError(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
getClientPointerChannel.setMessageHandler(nil)
|
|
||||||
}
|
|
||||||
let setRequestHeadersChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
|
||||||
if let api = api {
|
|
||||||
setRequestHeadersChannel.setMessageHandler { message, reply in
|
|
||||||
let args = message as! [Any?]
|
|
||||||
let headersArg = args[0] as! [String: String]
|
|
||||||
let serverUrlsArg = args[1] as! [String]
|
|
||||||
do {
|
|
||||||
try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg)
|
|
||||||
reply(wrapResult(nil))
|
|
||||||
} catch {
|
|
||||||
reply(wrapError(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setRequestHeadersChannel.setMessageHandler(nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import native_video_player
|
|
||||||
|
|
||||||
enum ImportError: Error {
|
enum ImportError: Error {
|
||||||
case noFile
|
case noFile
|
||||||
@@ -17,26 +16,15 @@ class NetworkApiImpl: NetworkApi {
|
|||||||
self.viewController = viewController
|
self.viewController = viewController
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, any Error>) -> Void) {
|
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, any Error>) -> Void) {
|
||||||
let importer = CertImporter(promptText: promptText, completion: { [weak self] result in
|
let importer = CertImporter(promptText: promptText, completion: { [weak self] result in
|
||||||
self?.activeImporter = nil
|
self?.activeImporter = nil
|
||||||
completion(result)
|
completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) })
|
||||||
}, viewController: viewController)
|
}, viewController: viewController)
|
||||||
activeImporter = importer
|
activeImporter = importer
|
||||||
importer.load()
|
importer.load()
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasCertificate() throws -> Bool {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassIdentity,
|
|
||||||
kSecAttrLabel as String: CLIENT_CERT_LABEL,
|
|
||||||
kSecReturnRef as String: true,
|
|
||||||
]
|
|
||||||
var item: CFTypeRef?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
||||||
return status == errSecSuccess
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeCertificate(completion: @escaping (Result<Void, any Error>) -> Void) {
|
func removeCertificate(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||||
let status = clearCerts()
|
let status = clearCerts()
|
||||||
if status == errSecSuccess || status == errSecItemNotFound {
|
if status == errSecSuccess || status == errSecItemNotFound {
|
||||||
@@ -52,58 +40,14 @@ class NetworkApiImpl: NetworkApi {
|
|||||||
}
|
}
|
||||||
completion(.failure(ImportError.keychainError(status)))
|
completion(.failure(ImportError.keychainError(status)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getClientPointer() throws -> Int64 {
|
|
||||||
let pointer = URLSessionManager.shared.sessionPointer
|
|
||||||
return Int64(Int(bitPattern: pointer))
|
|
||||||
}
|
|
||||||
|
|
||||||
func setRequestHeaders(headers: [String : String], serverUrls: [String]) throws {
|
|
||||||
var headers = headers
|
|
||||||
if let token = headers.removeValue(forKey: "x-immich-user-token") {
|
|
||||||
for serverUrl in serverUrls {
|
|
||||||
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
|
|
||||||
let isSecure = serverUrl.hasPrefix("https")
|
|
||||||
let cookies: [(String, String, Bool)] = [
|
|
||||||
("immich_access_token", token, true),
|
|
||||||
("immich_is_authenticated", "true", false),
|
|
||||||
("immich_auth_type", "password", true),
|
|
||||||
]
|
|
||||||
let expiry = Date().addingTimeInterval(400 * 24 * 60 * 60)
|
|
||||||
for (name, value, httpOnly) in cookies {
|
|
||||||
var properties: [HTTPCookiePropertyKey: Any] = [
|
|
||||||
.name: name,
|
|
||||||
.value: value,
|
|
||||||
.domain: domain,
|
|
||||||
.path: "/",
|
|
||||||
.expires: expiry,
|
|
||||||
]
|
|
||||||
if isSecure { properties[.secure] = "TRUE" }
|
|
||||||
if httpOnly { properties[.init("HttpOnly")] = "TRUE" }
|
|
||||||
if let cookie = HTTPCookie(properties: properties) {
|
|
||||||
URLSessionManager.cookieStorage.setCookie(cookie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if serverUrls.first != UserDefaults.group.string(forKey: SERVER_URL_KEY) {
|
|
||||||
UserDefaults.group.set(serverUrls.first, forKey: SERVER_URL_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] {
|
|
||||||
UserDefaults.group.set(headers, forKey: HEADERS_KEY)
|
|
||||||
URLSessionManager.shared.recreateSession() // Recreate session to apply custom headers without app restart
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CertImporter: NSObject, UIDocumentPickerDelegate {
|
private class CertImporter: NSObject, UIDocumentPickerDelegate {
|
||||||
private let promptText: ClientCertPrompt
|
private let promptText: ClientCertPrompt
|
||||||
private var completion: ((Result<Void, Error>) -> Void)
|
private var completion: ((Result<(Data, String), Error>) -> Void)
|
||||||
private weak var viewController: UIViewController?
|
private weak var viewController: UIViewController?
|
||||||
|
|
||||||
init(promptText: ClientCertPrompt, completion: (@escaping (Result<Void, Error>) -> Void), viewController: UIViewController?) {
|
init(promptText: ClientCertPrompt, completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) {
|
||||||
self.promptText = promptText
|
self.promptText = promptText
|
||||||
self.completion = completion
|
self.completion = completion
|
||||||
self.viewController = viewController
|
self.viewController = viewController
|
||||||
@@ -137,7 +81,7 @@ private class CertImporter: NSObject, UIDocumentPickerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await URLSessionManager.shared.session.flush()
|
await URLSessionManager.shared.session.flush()
|
||||||
self.completion(.success(()))
|
self.completion(.success((data, password)))
|
||||||
} catch {
|
} catch {
|
||||||
completion(.failure(error))
|
completion(.failure(error))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,49 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import native_video_player
|
|
||||||
|
|
||||||
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
|
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
|
||||||
let HEADERS_KEY = "immich.request_headers"
|
|
||||||
let SERVER_URL_KEY = "immich.server_url"
|
|
||||||
let APP_GROUP = "group.app.immich.share"
|
|
||||||
|
|
||||||
extension UserDefaults {
|
|
||||||
static let group = UserDefaults(suiteName: APP_GROUP)!
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manages a shared URLSession with SSL configuration support.
|
/// Manages a shared URLSession with SSL configuration support.
|
||||||
/// Old sessions are kept alive by Dart's FFI retain until all isolates release them.
|
|
||||||
class URLSessionManager: NSObject {
|
class URLSessionManager: NSObject {
|
||||||
static let shared = URLSessionManager()
|
static let shared = URLSessionManager()
|
||||||
|
|
||||||
private(set) var session: URLSession
|
let session: URLSession
|
||||||
let delegate: URLSessionManagerDelegate
|
private let configuration = {
|
||||||
private static let cacheDir: URL = {
|
let config = URLSessionConfiguration.default
|
||||||
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
|
|
||||||
|
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
|
||||||
.first!
|
.first!
|
||||||
.appendingPathComponent("api", isDirectory: true)
|
.appendingPathComponent("api", isDirectory: true)
|
||||||
try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||||
return dir
|
|
||||||
}()
|
|
||||||
private static let urlCache = URLCache(
|
|
||||||
memoryCapacity: 0,
|
|
||||||
diskCapacity: 1024 * 1024 * 1024,
|
|
||||||
directory: cacheDir
|
|
||||||
)
|
|
||||||
private static let userAgent: String = {
|
|
||||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
|
||||||
return "Immich_iOS_\(version)"
|
|
||||||
}()
|
|
||||||
static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP)
|
|
||||||
|
|
||||||
var sessionPointer: UnsafeMutableRawPointer {
|
config.urlCache = URLCache(
|
||||||
Unmanaged.passUnretained(session).toOpaque()
|
memoryCapacity: 0,
|
||||||
}
|
diskCapacity: 1024 * 1024 * 1024,
|
||||||
|
directory: cacheDir
|
||||||
|
)
|
||||||
|
|
||||||
private override init() {
|
|
||||||
delegate = URLSessionManagerDelegate()
|
|
||||||
session = Self.buildSession(delegate: delegate)
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func recreateSession() {
|
|
||||||
session = Self.buildSession(delegate: delegate)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
|
|
||||||
let config = URLSessionConfiguration.default
|
|
||||||
config.urlCache = urlCache
|
|
||||||
config.httpCookieStorage = cookieStorage
|
|
||||||
config.httpMaximumConnectionsPerHost = 64
|
config.httpMaximumConnectionsPerHost = 64
|
||||||
config.timeoutIntervalForRequest = 60
|
config.timeoutIntervalForRequest = 60
|
||||||
config.timeoutIntervalForResource = 300
|
config.timeoutIntervalForResource = 300
|
||||||
|
|
||||||
var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:]
|
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||||
headers["User-Agent"] = headers["User-Agent"] ?? userAgent
|
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
|
||||||
config.httpAdditionalHeaders = headers
|
|
||||||
|
|
||||||
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
return config
|
||||||
|
}()
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil)
|
||||||
|
super.init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
|
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate {
|
||||||
func urlSession(
|
func urlSession(
|
||||||
_ session: URLSession,
|
_ session: URLSession,
|
||||||
didReceive challenge: URLAuthenticationChallenge,
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
) {
|
) {
|
||||||
handleChallenge(session, challenge, completionHandler)
|
handleChallenge(challenge, completionHandler: completionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(
|
func urlSession(
|
||||||
@@ -80,24 +52,20 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
|||||||
didReceive challenge: URLAuthenticationChallenge,
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
) {
|
) {
|
||||||
handleChallenge(session, challenge, completionHandler, task: task)
|
handleChallenge(challenge, completionHandler: completionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleChallenge(
|
func handleChallenge(
|
||||||
_ session: URLSession,
|
|
||||||
_ challenge: URLAuthenticationChallenge,
|
_ challenge: URLAuthenticationChallenge,
|
||||||
_ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void,
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
task: URLSessionTask? = nil
|
|
||||||
) {
|
) {
|
||||||
switch challenge.protectionSpace.authenticationMethod {
|
switch challenge.protectionSpace.authenticationMethod {
|
||||||
case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(session, completion: completionHandler)
|
case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(completion: completionHandler)
|
||||||
case NSURLAuthenticationMethodHTTPBasic: handleBasicAuth(session, task: task, completion: completionHandler)
|
|
||||||
default: completionHandler(.performDefaultHandling, nil)
|
default: completionHandler(.performDefaultHandling, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleClientCertificate(
|
private func handleClientCertificate(
|
||||||
_ session: URLSession,
|
|
||||||
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
) {
|
) {
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
@@ -112,29 +80,8 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
|||||||
let credential = URLCredential(identity: identity as! SecIdentity,
|
let credential = URLCredential(identity: identity as! SecIdentity,
|
||||||
certificates: nil,
|
certificates: nil,
|
||||||
persistence: .forSession)
|
persistence: .forSession)
|
||||||
if #available(iOS 15, *) {
|
|
||||||
VideoProxyServer.shared.session = session
|
|
||||||
}
|
|
||||||
return completion(.useCredential, credential)
|
return completion(.useCredential, credential)
|
||||||
}
|
}
|
||||||
completion(.performDefaultHandling, nil)
|
completion(.performDefaultHandling, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleBasicAuth(
|
|
||||||
_ session: URLSession,
|
|
||||||
task: URLSessionTask?,
|
|
||||||
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
|
||||||
) {
|
|
||||||
guard let url = task?.originalRequest?.url,
|
|
||||||
let user = url.user,
|
|
||||||
let password = url.password
|
|
||||||
else {
|
|
||||||
return completion(.performDefaultHandling, nil)
|
|
||||||
}
|
|
||||||
if #available(iOS 15, *) {
|
|
||||||
VideoProxyServer.shared.session = session
|
|
||||||
}
|
|
||||||
let credential = URLCredential(user: user, password: password, persistence: .forSession)
|
|
||||||
completion(.useCredential, credential)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
|||||||
|
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol LocalImageApi {
|
protocol LocalImageApi {
|
||||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
||||||
func cancelRequest(requestId: Int64) throws
|
func cancelRequest(requestId: Int64) throws
|
||||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
|
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
|
||||||
}
|
}
|
||||||
@@ -90,8 +90,7 @@ class LocalImageApiSetup {
|
|||||||
let widthArg = args[2] as! Int64
|
let widthArg = args[2] as! Int64
|
||||||
let heightArg = args[3] as! Int64
|
let heightArg = args[3] as! Int64
|
||||||
let isVideoArg = args[4] as! Bool
|
let isVideoArg = args[4] as! Bool
|
||||||
let preferEncodedArg = args[5] as! Bool
|
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg) { result in
|
||||||
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg, preferEncoded: preferEncodedArg) { result in
|
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let res):
|
case .success(let res):
|
||||||
reply(wrapResult(res))
|
reply(wrapResult(res))
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||||
let request = LocalImageRequest(callback: completion)
|
let request = LocalImageRequest(callback: completion)
|
||||||
let item = DispatchWorkItem {
|
let item = DispatchWorkItem {
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
@@ -91,49 +91,6 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
return completion(ImageProcessing.cancelledResult)
|
return completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
if preferEncoded {
|
|
||||||
let dataOptions = PHImageRequestOptions()
|
|
||||||
dataOptions.isNetworkAccessAllowed = true
|
|
||||||
dataOptions.isSynchronous = true
|
|
||||||
dataOptions.version = .current
|
|
||||||
|
|
||||||
var imageData: Data?
|
|
||||||
Self.imageManager.requestImageDataAndOrientation(
|
|
||||||
for: asset,
|
|
||||||
options: dataOptions,
|
|
||||||
resultHandler: { (data, _, _, _) in
|
|
||||||
imageData = data
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if request.isCancelled {
|
|
||||||
Self.remove(requestId: requestId)
|
|
||||||
return completion(ImageProcessing.cancelledResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let data = imageData else {
|
|
||||||
Self.remove(requestId: requestId)
|
|
||||||
return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil)))
|
|
||||||
}
|
|
||||||
|
|
||||||
let length = data.count
|
|
||||||
let pointer = malloc(length)!
|
|
||||||
data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length)
|
|
||||||
|
|
||||||
if request.isCancelled {
|
|
||||||
free(pointer)
|
|
||||||
Self.remove(requestId: requestId)
|
|
||||||
return completion(ImageProcessing.cancelledResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.callback(.success([
|
|
||||||
"pointer": Int64(Int(bitPattern: pointer)),
|
|
||||||
"length": Int64(length),
|
|
||||||
]))
|
|
||||||
Self.remove(requestId: requestId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var image: UIImage?
|
var image: UIImage?
|
||||||
Self.imageManager.requestImage(
|
Self.imageManager.requestImage(
|
||||||
for: asset,
|
for: asset,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
|
|||||||
|
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol RemoteImageApi {
|
protocol RemoteImageApi {
|
||||||
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
||||||
func cancelRequest(requestId: Int64) throws
|
func cancelRequest(requestId: Int64) throws
|
||||||
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
|
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
|
||||||
}
|
}
|
||||||
@@ -86,9 +86,9 @@ class RemoteImageApiSetup {
|
|||||||
requestImageChannel.setMessageHandler { message, reply in
|
requestImageChannel.setMessageHandler { message, reply in
|
||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let urlArg = args[0] as! String
|
let urlArg = args[0] as! String
|
||||||
let requestIdArg = args[1] as! Int64
|
let headersArg = args[1] as! [String: String]
|
||||||
let preferEncodedArg = args[2] as! Bool
|
let requestIdArg = args[2] as! Int64
|
||||||
api.requestImage(url: urlArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
|
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let res):
|
case .success(let res):
|
||||||
reply(wrapResult(res))
|
reply(wrapResult(res))
|
||||||
|
|||||||
@@ -33,12 +33,15 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||||||
kCGImageSourceCreateThumbnailFromImageAlways: true
|
kCGImageSourceCreateThumbnailFromImageAlways: true
|
||||||
] as CFDictionary
|
] as CFDictionary
|
||||||
|
|
||||||
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
||||||
var urlRequest = URLRequest(url: URL(string: url)!)
|
var urlRequest = URLRequest(url: URL(string: url)!)
|
||||||
urlRequest.cachePolicy = .returnCacheDataElseLoad
|
urlRequest.cachePolicy = .returnCacheDataElseLoad
|
||||||
|
for (key, value) in headers {
|
||||||
|
urlRequest.setValue(value, forHTTPHeaderField: key)
|
||||||
|
}
|
||||||
|
|
||||||
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
|
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
|
||||||
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error)
|
Self.handleCompletion(requestId: requestId, data: data, response: response, error: error)
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
|
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
|
||||||
@@ -50,7 +53,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
|
private static func handleCompletion(requestId: Int64, data: Data?, response: URLResponse?, error: Error?) {
|
||||||
os_unfair_lock_lock(&Self.lock)
|
os_unfair_lock_lock(&Self.lock)
|
||||||
guard let request = requests[requestId] else {
|
guard let request = requests[requestId] else {
|
||||||
return os_unfair_lock_unlock(&Self.lock)
|
return os_unfair_lock_unlock(&Self.lock)
|
||||||
@@ -81,24 +84,6 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||||||
return request.completion(ImageProcessing.cancelledResult)
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return raw encoded bytes when requested (for animated images)
|
|
||||||
if encoded {
|
|
||||||
let length = data.count
|
|
||||||
let pointer = malloc(length)!
|
|
||||||
data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length)
|
|
||||||
|
|
||||||
if request.isCancelled {
|
|
||||||
free(pointer)
|
|
||||||
return request.completion(ImageProcessing.cancelledResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
return request.completion(
|
|
||||||
.success([
|
|
||||||
"pointer": Int64(Int(bitPattern: pointer)),
|
|
||||||
"length": Int64(length),
|
|
||||||
]))
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
|
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
|
||||||
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
|
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
|
||||||
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
|
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
|
||||||
|
|||||||
@@ -128,15 +128,6 @@ func deepHashMessages(value: Any?, hasher: inout Hasher) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
enum PlatformAssetPlaybackStyle: Int {
|
|
||||||
case unknown = 0
|
|
||||||
case image = 1
|
|
||||||
case video = 2
|
|
||||||
case imageAnimated = 3
|
|
||||||
case livePhoto = 4
|
|
||||||
case videoLooping = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generated class from Pigeon that represents data sent in messages.
|
/// Generated class from Pigeon that represents data sent in messages.
|
||||||
struct PlatformAsset: Hashable {
|
struct PlatformAsset: Hashable {
|
||||||
var id: String
|
var id: String
|
||||||
@@ -152,7 +143,6 @@ struct PlatformAsset: Hashable {
|
|||||||
var adjustmentTime: Int64? = nil
|
var adjustmentTime: Int64? = nil
|
||||||
var latitude: Double? = nil
|
var latitude: Double? = nil
|
||||||
var longitude: Double? = nil
|
var longitude: Double? = nil
|
||||||
var playbackStyle: PlatformAssetPlaybackStyle
|
|
||||||
|
|
||||||
|
|
||||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||||
@@ -170,7 +160,6 @@ struct PlatformAsset: Hashable {
|
|||||||
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
|
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
|
||||||
let latitude: Double? = nilOrValue(pigeonVar_list[11])
|
let latitude: Double? = nilOrValue(pigeonVar_list[11])
|
||||||
let longitude: Double? = nilOrValue(pigeonVar_list[12])
|
let longitude: Double? = nilOrValue(pigeonVar_list[12])
|
||||||
let playbackStyle = pigeonVar_list[13] as! PlatformAssetPlaybackStyle
|
|
||||||
|
|
||||||
return PlatformAsset(
|
return PlatformAsset(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -185,8 +174,7 @@ struct PlatformAsset: Hashable {
|
|||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
adjustmentTime: adjustmentTime,
|
adjustmentTime: adjustmentTime,
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude
|
||||||
playbackStyle: playbackStyle
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
func toList() -> [Any?] {
|
func toList() -> [Any?] {
|
||||||
@@ -204,7 +192,6 @@ struct PlatformAsset: Hashable {
|
|||||||
adjustmentTime,
|
adjustmentTime,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
playbackStyle,
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
|
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
|
||||||
@@ -362,20 +349,14 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
|||||||
override func readValue(ofType type: UInt8) -> Any? {
|
override func readValue(ofType type: UInt8) -> Any? {
|
||||||
switch type {
|
switch type {
|
||||||
case 129:
|
case 129:
|
||||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
|
||||||
if let enumResultAsInt = enumResultAsInt {
|
|
||||||
return PlatformAssetPlaybackStyle(rawValue: enumResultAsInt)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case 130:
|
|
||||||
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
||||||
case 131:
|
case 130:
|
||||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||||
case 132:
|
case 131:
|
||||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||||
case 133:
|
case 132:
|
||||||
return HashResult.fromList(self.readValue() as! [Any?])
|
return HashResult.fromList(self.readValue() as! [Any?])
|
||||||
case 134:
|
case 133:
|
||||||
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
||||||
default:
|
default:
|
||||||
return super.readValue(ofType: type)
|
return super.readValue(ofType: type)
|
||||||
@@ -385,23 +366,20 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
|||||||
|
|
||||||
private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
||||||
override func writeValue(_ value: Any) {
|
override func writeValue(_ value: Any) {
|
||||||
if let value = value as? PlatformAssetPlaybackStyle {
|
if let value = value as? PlatformAsset {
|
||||||
super.writeByte(129)
|
super.writeByte(129)
|
||||||
super.writeValue(value.rawValue)
|
|
||||||
} else if let value = value as? PlatformAsset {
|
|
||||||
super.writeByte(130)
|
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? PlatformAlbum {
|
} else if let value = value as? PlatformAlbum {
|
||||||
super.writeByte(131)
|
super.writeByte(130)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? SyncDelta {
|
} else if let value = value as? SyncDelta {
|
||||||
super.writeByte(132)
|
super.writeByte(131)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? HashResult {
|
} else if let value = value as? HashResult {
|
||||||
super.writeByte(133)
|
super.writeByte(132)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? CloudIdResult {
|
} else if let value = value as? CloudIdResult {
|
||||||
super.writeByte(134)
|
super.writeByte(133)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(value)
|
super.writeValue(value)
|
||||||
|
|||||||
@@ -173,8 +173,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
type: 0,
|
type: 0,
|
||||||
durationInSeconds: 0,
|
durationInSeconds: 0,
|
||||||
orientation: 0,
|
orientation: 0,
|
||||||
isFavorite: false,
|
isFavorite: false
|
||||||
playbackStyle: .unknown
|
|
||||||
)
|
)
|
||||||
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
import Photos
|
import Photos
|
||||||
|
|
||||||
extension PHAsset {
|
extension PHAsset {
|
||||||
var platformPlaybackStyle: PlatformAssetPlaybackStyle {
|
|
||||||
switch playbackStyle {
|
|
||||||
case .image: return .image
|
|
||||||
case .imageAnimated: return .imageAnimated
|
|
||||||
case .livePhoto: return .livePhoto
|
|
||||||
case .video: return .video
|
|
||||||
case .videoLooping: return .videoLooping
|
|
||||||
@unknown default: return .unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPlatformAsset() -> PlatformAsset {
|
func toPlatformAsset() -> PlatformAsset {
|
||||||
return PlatformAsset(
|
return PlatformAsset(
|
||||||
id: localIdentifier,
|
id: localIdentifier,
|
||||||
@@ -26,8 +15,7 @@ extension PHAsset {
|
|||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
adjustmentTime: adjustmentTimestamp,
|
adjustmentTime: adjustmentTimestamp,
|
||||||
latitude: location?.coordinate.latitude,
|
latitude: location?.coordinate.latitude,
|
||||||
longitude: location?.coordinate.longitude,
|
longitude: location?.coordinate.longitude
|
||||||
playbackStyle: platformPlaybackStyle
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,5 +18,3 @@ enum ActionSource { timeline, viewer }
|
|||||||
enum CleanupStep { selectDate, scan, delete }
|
enum CleanupStep { selectDate, scan, delete }
|
||||||
|
|
||||||
enum AssetKeepType { none, photosOnly, videosOnly }
|
enum AssetKeepType { none, photosOnly, videosOnly }
|
||||||
|
|
||||||
enum AssetDateAggregation { start, end }
|
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ enum AssetType {
|
|||||||
|
|
||||||
enum AssetState { local, remote, merged }
|
enum AssetState { local, remote, merged }
|
||||||
|
|
||||||
// do not change!
|
|
||||||
// keep in sync with PlatformAssetPlaybackStyle
|
|
||||||
enum AssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
|
||||||
|
|
||||||
sealed class BaseAsset {
|
sealed class BaseAsset {
|
||||||
final String name;
|
final String name;
|
||||||
final String? checksum;
|
final String? checksum;
|
||||||
@@ -47,14 +43,6 @@ sealed class BaseAsset {
|
|||||||
|
|
||||||
bool get isMotionPhoto => livePhotoVideoId != null;
|
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||||
|
|
||||||
AssetPlaybackStyle get playbackStyle {
|
|
||||||
if (isVideo) return AssetPlaybackStyle.video;
|
|
||||||
if (isMotionPhoto) return AssetPlaybackStyle.livePhoto;
|
|
||||||
if (isImage && durationInSeconds != null && durationInSeconds! > 0) return AssetPlaybackStyle.imageAnimated;
|
|
||||||
if (isImage) return AssetPlaybackStyle.image;
|
|
||||||
return AssetPlaybackStyle.unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
Duration get duration {
|
Duration get duration {
|
||||||
final durationInSeconds = this.durationInSeconds;
|
final durationInSeconds = this.durationInSeconds;
|
||||||
if (durationInSeconds != null) {
|
if (durationInSeconds != null) {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ class LocalAsset extends BaseAsset {
|
|||||||
final String? remoteAssetId;
|
final String? remoteAssetId;
|
||||||
final String? cloudId;
|
final String? cloudId;
|
||||||
final int orientation;
|
final int orientation;
|
||||||
@override
|
|
||||||
final AssetPlaybackStyle playbackStyle;
|
|
||||||
|
|
||||||
final DateTime? adjustmentTime;
|
final DateTime? adjustmentTime;
|
||||||
final double? latitude;
|
final double? latitude;
|
||||||
@@ -27,7 +25,6 @@ class LocalAsset extends BaseAsset {
|
|||||||
super.isFavorite = false,
|
super.isFavorite = false,
|
||||||
super.livePhotoVideoId,
|
super.livePhotoVideoId,
|
||||||
this.orientation = 0,
|
this.orientation = 0,
|
||||||
required this.playbackStyle,
|
|
||||||
this.adjustmentTime,
|
this.adjustmentTime,
|
||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
@@ -59,7 +56,6 @@ class LocalAsset extends BaseAsset {
|
|||||||
width: ${width ?? "<NA>"},
|
width: ${width ?? "<NA>"},
|
||||||
height: ${height ?? "<NA>"},
|
height: ${height ?? "<NA>"},
|
||||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||||
playbackStyle: $playbackStyle,
|
|
||||||
remoteId: ${remoteId ?? "<NA>"},
|
remoteId: ${remoteId ?? "<NA>"},
|
||||||
cloudId: ${cloudId ?? "<NA>"},
|
cloudId: ${cloudId ?? "<NA>"},
|
||||||
checksum: ${checksum ?? "<NA>"},
|
checksum: ${checksum ?? "<NA>"},
|
||||||
@@ -80,7 +76,6 @@ class LocalAsset extends BaseAsset {
|
|||||||
id == other.id &&
|
id == other.id &&
|
||||||
cloudId == other.cloudId &&
|
cloudId == other.cloudId &&
|
||||||
orientation == other.orientation &&
|
orientation == other.orientation &&
|
||||||
playbackStyle == other.playbackStyle &&
|
|
||||||
adjustmentTime == other.adjustmentTime &&
|
adjustmentTime == other.adjustmentTime &&
|
||||||
latitude == other.latitude &&
|
latitude == other.latitude &&
|
||||||
longitude == other.longitude;
|
longitude == other.longitude;
|
||||||
@@ -92,7 +87,6 @@ class LocalAsset extends BaseAsset {
|
|||||||
id.hashCode ^
|
id.hashCode ^
|
||||||
remoteId.hashCode ^
|
remoteId.hashCode ^
|
||||||
orientation.hashCode ^
|
orientation.hashCode ^
|
||||||
playbackStyle.hashCode ^
|
|
||||||
adjustmentTime.hashCode ^
|
adjustmentTime.hashCode ^
|
||||||
latitude.hashCode ^
|
latitude.hashCode ^
|
||||||
longitude.hashCode;
|
longitude.hashCode;
|
||||||
@@ -111,7 +105,6 @@ class LocalAsset extends BaseAsset {
|
|||||||
int? durationInSeconds,
|
int? durationInSeconds,
|
||||||
bool? isFavorite,
|
bool? isFavorite,
|
||||||
int? orientation,
|
int? orientation,
|
||||||
AssetPlaybackStyle? playbackStyle,
|
|
||||||
DateTime? adjustmentTime,
|
DateTime? adjustmentTime,
|
||||||
double? latitude,
|
double? latitude,
|
||||||
double? longitude,
|
double? longitude,
|
||||||
@@ -131,7 +124,6 @@ class LocalAsset extends BaseAsset {
|
|||||||
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
|
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
|
||||||
isFavorite: isFavorite ?? this.isFavorite,
|
isFavorite: isFavorite ?? this.isFavorite,
|
||||||
orientation: orientation ?? this.orientation,
|
orientation: orientation ?? this.orientation,
|
||||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
|
||||||
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
||||||
latitude: latitude ?? this.latitude,
|
latitude: latitude ?? this.latitude,
|
||||||
longitude: longitude ?? this.longitude,
|
longitude: longitude ?? this.longitude,
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import "package:openapi/api.dart" as api show AssetEditAction;
|
|
||||||
|
|
||||||
enum AssetEditAction { rotate, crop, mirror, other }
|
|
||||||
|
|
||||||
extension AssetEditActionExtension on AssetEditAction {
|
|
||||||
api.AssetEditAction? toDto() {
|
|
||||||
return switch (this) {
|
|
||||||
AssetEditAction.rotate => api.AssetEditAction.rotate,
|
|
||||||
AssetEditAction.crop => api.AssetEditAction.crop,
|
|
||||||
AssetEditAction.mirror => api.AssetEditAction.mirror,
|
|
||||||
AssetEditAction.other => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AssetEdit {
|
|
||||||
final AssetEditAction action;
|
|
||||||
final Map<String, dynamic> parameters;
|
|
||||||
|
|
||||||
const AssetEdit({required this.action, required this.parameters});
|
|
||||||
}
|
|
||||||
@@ -3,21 +3,30 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
|
|
||||||
class SearchResult {
|
class SearchResult {
|
||||||
final List<BaseAsset> assets;
|
final List<BaseAsset> assets;
|
||||||
|
final double scrollOffset;
|
||||||
final int? nextPage;
|
final int? nextPage;
|
||||||
|
|
||||||
const SearchResult({required this.assets, this.nextPage});
|
const SearchResult({required this.assets, this.scrollOffset = 0.0, this.nextPage});
|
||||||
|
|
||||||
|
SearchResult copyWith({List<BaseAsset>? assets, int? nextPage, double? scrollOffset}) {
|
||||||
|
return SearchResult(
|
||||||
|
assets: assets ?? this.assets,
|
||||||
|
nextPage: nextPage ?? this.nextPage,
|
||||||
|
scrollOffset: scrollOffset ?? this.scrollOffset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage)';
|
String toString() => 'SearchResult(assets: ${assets.length}, nextPage: $nextPage, scrollOffset: $scrollOffset)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(covariant SearchResult other) {
|
bool operator ==(covariant SearchResult other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
final listEquals = const DeepCollectionEquality().equals;
|
final listEquals = const DeepCollectionEquality().equals;
|
||||||
|
|
||||||
return listEquals(other.assets, assets) && other.nextPage == nextPage;
|
return listEquals(other.assets, assets) && other.nextPage == nextPage && other.scrollOffset == scrollOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => assets.hashCode ^ nextPage.hashCode;
|
int get hashCode => assets.hashCode ^ nextPage.hashCode ^ scrollOffset.hashCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,9 +73,6 @@ enum StoreKey<T> {
|
|||||||
autoPlayVideo<bool>._(139),
|
autoPlayVideo<bool>._(139),
|
||||||
albumGridView<bool>._(140),
|
albumGridView<bool>._(140),
|
||||||
|
|
||||||
// Image viewer navigation settings
|
|
||||||
tapToNavigate<bool>._(141),
|
|
||||||
|
|
||||||
// Experimental stuff
|
// Experimental stuff
|
||||||
photoManagerCustomFilter<bool>._(1000),
|
photoManagerCustomFilter<bool>._(1000),
|
||||||
betaPromptShown<bool>._(1001),
|
betaPromptShown<bool>._(1001),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:background_downloader/background_downloader.dart';
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
|
import 'package:cancellation_token_http/http.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
@@ -27,6 +28,7 @@ import 'package:immich_mobile/services/localization.service.dart';
|
|||||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
|
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
import 'package:immich_mobile/wm_executor.dart';
|
import 'package:immich_mobile/wm_executor.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -62,7 +64,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
final Drift _drift;
|
final Drift _drift;
|
||||||
final DriftLogger _driftLogger;
|
final DriftLogger _driftLogger;
|
||||||
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
||||||
final _cancellationToken = Completer<void>();
|
final CancellationToken _cancellationToken = CancellationToken();
|
||||||
final Logger _logger = Logger('BackgroundWorkerBgService');
|
final Logger _logger = Logger('BackgroundWorkerBgService');
|
||||||
|
|
||||||
bool _isCleanedUp = false;
|
bool _isCleanedUp = false;
|
||||||
@@ -86,6 +88,8 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
try {
|
try {
|
||||||
|
HttpSSLOptions.apply();
|
||||||
|
|
||||||
await Future.wait(
|
await Future.wait(
|
||||||
[
|
[
|
||||||
loadTranslations(),
|
loadTranslations(),
|
||||||
@@ -194,7 +198,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
_ref?.dispose();
|
_ref?.dispose();
|
||||||
_ref = null;
|
_ref = null;
|
||||||
|
|
||||||
_cancellationToken.complete();
|
_cancellationToken.cancel();
|
||||||
_logger.info("Cleaning up background worker");
|
_logger.info("Cleaning up background worker");
|
||||||
|
|
||||||
final cleanupFutures = [
|
final cleanupFutures = [
|
||||||
|
|||||||
@@ -435,19 +435,9 @@ extension PlatformToLocalAsset on PlatformAsset {
|
|||||||
durationInSeconds: durationInSeconds,
|
durationInSeconds: durationInSeconds,
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
orientation: orientation,
|
orientation: orientation,
|
||||||
playbackStyle: _toPlaybackStyle(playbackStyle),
|
|
||||||
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
|
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
|
|
||||||
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
|
|
||||||
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
|
|
||||||
PlatformAssetPlaybackStyle.video => AssetPlaybackStyle.video,
|
|
||||||
PlatformAssetPlaybackStyle.imageAnimated => AssetPlaybackStyle.imageAnimated,
|
|
||||||
PlatformAssetPlaybackStyle.livePhoto => AssetPlaybackStyle.livePhoto,
|
|
||||||
PlatformAssetPlaybackStyle.videoLooping => AssetPlaybackStyle.videoLooping,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ class RemoteAlbumService {
|
|||||||
AlbumSortMode.title => albums.sortedBy((album) => album.name),
|
AlbumSortMode.title => albums.sortedBy((album) => album.name),
|
||||||
AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
|
AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
|
||||||
AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
|
AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
|
||||||
AlbumSortMode.mostRecent => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.end),
|
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
|
||||||
AlbumSortMode.mostOldest => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.start),
|
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
|
||||||
};
|
};
|
||||||
final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder;
|
final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder;
|
||||||
|
|
||||||
@@ -172,25 +172,46 @@ class RemoteAlbumService {
|
|||||||
return _repository.getAlbumsContainingAsset(assetId);
|
return _repository.getAlbumsContainingAsset(assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<RemoteAlbum>> _sortByAssetDate(
|
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
|
||||||
List<RemoteAlbum> albums, {
|
// map album IDs to their newest asset dates
|
||||||
required AssetDateAggregation aggregation,
|
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
|
||||||
}) async {
|
for (final album in albums) {
|
||||||
if (albums.isEmpty) return [];
|
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
|
||||||
|
|
||||||
final albumIds = albums.map((e) => e.id).toList();
|
|
||||||
final sortedIds = await _repository.getSortedAlbumIds(albumIds, aggregation: aggregation);
|
|
||||||
|
|
||||||
final albumMap = Map<String, RemoteAlbum>.fromEntries(albums.map((a) => MapEntry(a.id, a)));
|
|
||||||
|
|
||||||
final sortedAlbums = sortedIds.map((id) => albumMap[id]).whereType<RemoteAlbum>().toList();
|
|
||||||
|
|
||||||
if (sortedAlbums.length < albums.length) {
|
|
||||||
final returnedIdSet = sortedIds.toSet();
|
|
||||||
final emptyAlbums = albums.where((a) => !returnedIdSet.contains(a.id));
|
|
||||||
sortedAlbums.addAll(emptyAlbums);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sortedAlbums;
|
// await all database queries
|
||||||
|
final entries = await Future.wait(
|
||||||
|
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
|
||||||
|
);
|
||||||
|
final assetTimestamps = Map.fromEntries(entries);
|
||||||
|
|
||||||
|
final sorted = albums.sorted((a, b) {
|
||||||
|
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
return aDate.compareTo(bDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
|
||||||
|
// map album IDs to their oldest asset dates
|
||||||
|
final Map<String, Future<DateTime?>> assetTimestampFutures = {
|
||||||
|
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
// await all database queries
|
||||||
|
final entries = await Future.wait(
|
||||||
|
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
|
||||||
|
);
|
||||||
|
final assetTimestamps = Map.fromEntries(entries);
|
||||||
|
|
||||||
|
final sorted = albums.sorted((a, b) {
|
||||||
|
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
return aDate.compareTo(bDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,14 +70,13 @@ extension on AssetResponseDto {
|
|||||||
_ => AssetVisibility.timeline,
|
_ => AssetVisibility.timeline,
|
||||||
},
|
},
|
||||||
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
|
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
|
||||||
height: height?.toInt(),
|
height: exifInfo?.exifImageHeight?.toInt(),
|
||||||
width: width?.toInt(),
|
width: exifInfo?.exifImageWidth?.toInt(),
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
livePhotoVideoId: livePhotoVideoId,
|
livePhotoVideoId: livePhotoVideoId,
|
||||||
thumbHash: thumbhash,
|
thumbHash: thumbhash,
|
||||||
localId: null,
|
localId: null,
|
||||||
type: type.toAssetType(),
|
type: type.toAssetType(),
|
||||||
stackId: stack?.id,
|
|
||||||
isEdited: isEdited,
|
isEdited: isEdited,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,12 +68,12 @@ class SyncStreamService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final serverSemVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
|
final semVer = SemVer(major: serverVersion.major, minor: serverVersion.minor, patch: serverVersion.patch_);
|
||||||
|
|
||||||
final value = Store.get(StoreKey.syncMigrationStatus, "[]");
|
final value = Store.get(StoreKey.syncMigrationStatus, "[]");
|
||||||
final migrations = (jsonDecode(value) as List).cast<String>();
|
final migrations = (jsonDecode(value) as List).cast<String>();
|
||||||
int previousLength = migrations.length;
|
int previousLength = migrations.length;
|
||||||
await _runPreSyncTasks(migrations, serverSemVer);
|
await _runPreSyncTasks(migrations, semVer);
|
||||||
|
|
||||||
if (migrations.length != previousLength) {
|
if (migrations.length != previousLength) {
|
||||||
_logger.info("Updated pre-sync migration status: $migrations");
|
_logger.info("Updated pre-sync migration status: $migrations");
|
||||||
@@ -82,14 +82,10 @@ class SyncStreamService {
|
|||||||
|
|
||||||
// Start the sync stream and handle events
|
// Start the sync stream and handle events
|
||||||
bool shouldReset = false;
|
bool shouldReset = false;
|
||||||
await _syncApiRepository.streamChanges(
|
await _syncApiRepository.streamChanges(_handleEvents, onReset: () => shouldReset = true);
|
||||||
_handleEvents,
|
|
||||||
serverVersion: serverSemVer,
|
|
||||||
onReset: () => shouldReset = true,
|
|
||||||
);
|
|
||||||
if (shouldReset) {
|
if (shouldReset) {
|
||||||
_logger.info("Resetting sync state as requested by server");
|
_logger.info("Resetting sync state as requested by server");
|
||||||
await _syncApiRepository.streamChanges(_handleEvents, serverVersion: serverSemVer);
|
await _syncApiRepository.streamChanges(_handleEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
previousLength = migrations.length;
|
previousLength = migrations.length;
|
||||||
@@ -205,10 +201,6 @@ class SyncStreamService {
|
|||||||
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
||||||
case SyncEntityType.assetExifV1:
|
case SyncEntityType.assetExifV1:
|
||||||
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
||||||
case SyncEntityType.assetEditV1:
|
|
||||||
return _syncStreamRepository.updateAssetEditsV1(data.cast());
|
|
||||||
case SyncEntityType.assetEditDeleteV1:
|
|
||||||
return _syncStreamRepository.deleteAssetEditsV1(data.cast());
|
|
||||||
case SyncEntityType.assetMetadataV1:
|
case SyncEntityType.assetMetadataV1:
|
||||||
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
|
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
|
||||||
case SyncEntityType.assetMetadataDeleteV1:
|
case SyncEntityType.assetMetadataDeleteV1:
|
||||||
@@ -290,8 +282,6 @@ class SyncStreamService {
|
|||||||
return _syncStreamRepository.deletePeopleV1(data.cast());
|
return _syncStreamRepository.deletePeopleV1(data.cast());
|
||||||
case SyncEntityType.assetFaceV1:
|
case SyncEntityType.assetFaceV1:
|
||||||
return _syncStreamRepository.updateAssetFacesV1(data.cast());
|
return _syncStreamRepository.updateAssetFacesV1(data.cast());
|
||||||
case SyncEntityType.assetFaceV2:
|
|
||||||
return _syncStreamRepository.updateAssetFacesV2(data.cast());
|
|
||||||
case SyncEntityType.assetFaceDeleteV1:
|
case SyncEntityType.assetFaceDeleteV1:
|
||||||
return _syncStreamRepository.deleteAssetFacesV1(data.cast());
|
return _syncStreamRepository.deleteAssetFacesV1(data.cast());
|
||||||
default:
|
default:
|
||||||
@@ -340,43 +330,39 @@ class SyncStreamService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> handleWsAssetEditReadyV1(dynamic data) async {
|
Future<void> handleWsAssetEditReadyV1Batch(List<dynamic> batchData) async {
|
||||||
_logger.info('Processing AssetEditReadyV1 event');
|
if (batchData.isEmpty) return;
|
||||||
|
|
||||||
|
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
|
||||||
|
|
||||||
|
final List<SyncAssetV1> assets = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (data is! Map<String, dynamic>) {
|
for (final data in batchData) {
|
||||||
throw ArgumentError("Invalid data format for AssetEditReadyV1 event");
|
if (data is! Map<String, dynamic>) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final payload = data;
|
||||||
|
final assetData = payload['asset'];
|
||||||
|
|
||||||
|
if (assetData == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final asset = SyncAssetV1.fromJson(assetData);
|
||||||
|
|
||||||
|
if (asset != null) {
|
||||||
|
assets.add(asset);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final payload = data;
|
if (assets.isNotEmpty) {
|
||||||
|
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
|
||||||
if (payload['asset'] == null) {
|
_logger.info('Successfully processed ${assets.length} edited assets');
|
||||||
throw ArgumentError("Missing 'asset' field in AssetEditReadyV1 event data");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final asset = SyncAssetV1.fromJson(payload['asset']);
|
|
||||||
if (asset == null) {
|
|
||||||
throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV1 event data");
|
|
||||||
}
|
|
||||||
|
|
||||||
List<SyncAssetEditV1> assetEdits = [];
|
|
||||||
|
|
||||||
// Edits are only send on v2.6.0+
|
|
||||||
if (payload['edit'] != null && payload['edit'] is List<dynamic>) {
|
|
||||||
assetEdits = (payload['edit'] as List<dynamic>)
|
|
||||||
.map((e) => SyncAssetEditV1.fromJson(e))
|
|
||||||
.whereType<SyncAssetEditV1>()
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit');
|
|
||||||
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
|
|
||||||
|
|
||||||
_logger.info(
|
|
||||||
'Successfully processed AssetEditReadyV1 event for asset ${asset.id} with ${assetEdits.length} edits',
|
|
||||||
);
|
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
_logger.severe("Error processing AssetEditReadyV1 websocket event", error, stackTrace);
|
_logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,9 +78,6 @@ class TimelineFactory {
|
|||||||
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
|
TimelineService fromAssets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||||
TimelineService(_timelineRepository.fromAssets(assets, type));
|
TimelineService(_timelineRepository.fromAssets(assets, type));
|
||||||
|
|
||||||
TimelineService fromAssetStream(List<BaseAsset> Function() getAssets, Stream<int> assetCount, TimelineOrigin type) =>
|
|
||||||
TimelineService(_timelineRepository.fromAssetStream(getAssets, assetCount, type));
|
|
||||||
|
|
||||||
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||||
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
||||||
|
|
||||||
@@ -115,7 +112,7 @@ class TimelineService {
|
|||||||
|
|
||||||
if (totalAssets == 0) {
|
if (totalAssets == 0) {
|
||||||
_bufferOffset = 0;
|
_bufferOffset = 0;
|
||||||
_buffer = [];
|
_buffer.clear();
|
||||||
} else {
|
} else {
|
||||||
final int offset;
|
final int offset;
|
||||||
final int count;
|
final int count;
|
||||||
|
|||||||
@@ -196,11 +196,11 @@ class BackgroundSyncManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> syncWebsocketEdit(dynamic data) {
|
Future<void> syncWebsocketEditBatch(List<dynamic> batchData) {
|
||||||
if (_syncWebsocketTask != null) {
|
if (_syncWebsocketTask != null) {
|
||||||
return _syncWebsocketTask!.future;
|
return _syncWebsocketTask!.future;
|
||||||
}
|
}
|
||||||
_syncWebsocketTask = _handleWsAssetEditReadyV1(data);
|
_syncWebsocketTask = _handleWsAssetEditReadyV1Batch(batchData);
|
||||||
return _syncWebsocketTask!.whenComplete(() {
|
return _syncWebsocketTask!.whenComplete(() {
|
||||||
_syncWebsocketTask = null;
|
_syncWebsocketTask = null;
|
||||||
});
|
});
|
||||||
@@ -242,7 +242,7 @@ Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => ru
|
|||||||
debugLabel: 'websocket-batch',
|
debugLabel: 'websocket-batch',
|
||||||
);
|
);
|
||||||
|
|
||||||
Cancelable<void> _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle(
|
Cancelable<void> _handleWsAssetEditReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
|
||||||
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data),
|
computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1Batch(batchData),
|
||||||
debugLabel: 'websocket-edit',
|
debugLabel: 'websocket-edit',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,27 +33,12 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SnapScrollController extends ScrollController {
|
|
||||||
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
|
|
||||||
|
|
||||||
@override
|
|
||||||
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) =>
|
|
||||||
SnapScrollPosition(physics: physics, context: context, oldPosition: oldPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
class SnapScrollPosition extends ScrollPositionWithSingleContext {
|
|
||||||
double snapOffset;
|
|
||||||
|
|
||||||
SnapScrollPosition({required super.physics, required super.context, super.oldPosition, this.snapOffset = 0.0});
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get shouldIgnorePointer => false;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SnapScrollPhysics extends ScrollPhysics {
|
class SnapScrollPhysics extends ScrollPhysics {
|
||||||
static const _minFlingVelocity = 700.0;
|
static const _minFlingVelocity = 700.0;
|
||||||
static const minSnapDistance = 30.0;
|
static const minSnapDistance = 30.0;
|
||||||
|
|
||||||
|
static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
|
||||||
|
|
||||||
const SnapScrollPhysics({super.parent});
|
const SnapScrollPhysics({super.parent});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -81,21 +66,91 @@ class SnapScrollPhysics extends ScrollPhysics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ScrollSpringSimulation(spring, position.pixels, target(position, velocity, snapOffset), velocity);
|
return ScrollSpringSimulation(
|
||||||
|
_spring,
|
||||||
|
position.pixels,
|
||||||
|
target(position, velocity, snapOffset),
|
||||||
|
velocity,
|
||||||
|
tolerance: toleranceFor(position),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
SpringDescription get spring => SpringDescription.withDampingRatio(mass: .5, stiffness: 300);
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get allowImplicitScrolling => false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get allowUserScrolling => false;
|
|
||||||
|
|
||||||
static double target(ScrollMetrics position, double velocity, double snapOffset) {
|
static double target(ScrollMetrics position, double velocity, double snapOffset) {
|
||||||
if (velocity > _minFlingVelocity) return snapOffset;
|
if (velocity > _minFlingVelocity) return snapOffset;
|
||||||
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset;
|
if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset;
|
||||||
return position.pixels < minSnapDistance ? 0.0 : snapOffset;
|
return position.pixels < minSnapDistance ? 0.0 : snapOffset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SnapScrollPosition extends ScrollPositionWithSingleContext {
|
||||||
|
double snapOffset;
|
||||||
|
|
||||||
|
SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProxyScrollController extends ScrollController {
|
||||||
|
final ScrollController scrollController;
|
||||||
|
|
||||||
|
ProxyScrollController({required this.scrollController});
|
||||||
|
|
||||||
|
SnapScrollPosition get snapPosition => position as SnapScrollPosition;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
|
||||||
|
return ProxyScrollPosition(
|
||||||
|
scrollController: scrollController,
|
||||||
|
physics: physics,
|
||||||
|
context: context,
|
||||||
|
oldPosition: oldPosition,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProxyScrollPosition extends SnapScrollPosition {
|
||||||
|
final ScrollController scrollController;
|
||||||
|
|
||||||
|
ProxyScrollPosition({
|
||||||
|
required this.scrollController,
|
||||||
|
required super.physics,
|
||||||
|
required super.context,
|
||||||
|
super.oldPosition,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
double setPixels(double newPixels) {
|
||||||
|
final overscroll = super.setPixels(newPixels);
|
||||||
|
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
|
||||||
|
scrollController.position.forcePixels(pixels);
|
||||||
|
}
|
||||||
|
return overscroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void forcePixels(double value) {
|
||||||
|
super.forcePixels(value);
|
||||||
|
if (scrollController.hasClients && scrollController.position.pixels != pixels) {
|
||||||
|
scrollController.position.forcePixels(pixels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
|
||||||
|
? scrollController.position.maxScrollExtent
|
||||||
|
: super.maxScrollExtent;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions
|
||||||
|
? scrollController.position.minScrollExtent
|
||||||
|
: super.minScrollExtent;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension
|
||||||
|
? scrollController.position.viewportDimension
|
||||||
|
: super.viewportDimension;
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user