mirror of
https://github.com/immich-app/immich.git
synced 2026-06-03 12:45:26 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbdac440fd |
@@ -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"
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ jobs:
|
|||||||
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
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
name: Check PR Template
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
|
||||||
types: [opened, edited]
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
parse:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.event.pull_request.head.repo.fork == true }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
outputs:
|
|
||||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
sparse-checkout: .github/pull_request_template.md
|
|
||||||
sparse-checkout-cone-mode: false
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Check required sections
|
|
||||||
id: check
|
|
||||||
env:
|
|
||||||
BODY: ${{ github.event.pull_request.body }}
|
|
||||||
run: |
|
|
||||||
OK=true
|
|
||||||
while IFS= read -r header; do
|
|
||||||
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
|
||||||
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
|
||||||
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
act:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: parse
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Close PR
|
|
||||||
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
|
||||||
run: |
|
|
||||||
gh api graphql \
|
|
||||||
-f prId="$NODE_ID" \
|
|
||||||
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
|
|
||||||
-f query='
|
|
||||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
|
||||||
addComment(input: {
|
|
||||||
subjectId: $prId,
|
|
||||||
body: $body
|
|
||||||
}) {
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
closePullRequest(input: {
|
|
||||||
pullRequestId: $prId
|
|
||||||
}) {
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
|
|
||||||
- name: Reopen PR (sections now present, PR closed)
|
|
||||||
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
|
||||||
run: |
|
|
||||||
gh api graphql \
|
|
||||||
-f prId="$NODE_ID" \
|
|
||||||
-f query='
|
|
||||||
mutation ReopenPR($prId: ID!) {
|
|
||||||
reopenPullRequest(input: {
|
|
||||||
pullRequestId: $prId
|
|
||||||
}) {
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
@@ -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}}'
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ jobs:
|
|||||||
- device: rocm
|
- device: rocm
|
||||||
suffixes: '-rocm'
|
suffixes: '-rocm'
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
runner-mapping: '{"linux/amd64": "pokedex-large"}'
|
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
|
||||||
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@bd49ed7a5a6022149f79b6564df48177476a822b # multi-runner-build-workflow-v2.2.1
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
name: Manage release PR
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Generate a token
|
||||||
|
id: generate-token
|
||||||
|
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
persist-credentials: true
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
|
with:
|
||||||
|
node-version-file: './server/.nvmrc'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
|
- name: Determine release type
|
||||||
|
id: bump-type
|
||||||
|
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Bump versions
|
||||||
|
env:
|
||||||
|
TYPE: ${{ steps.bump-type.outputs.bump }}
|
||||||
|
run: |
|
||||||
|
if [ "$TYPE" == "none" ]; then
|
||||||
|
exit 1 # TODO: Is there a cleaner way to abort the workflow?
|
||||||
|
fi
|
||||||
|
misc/release/pump-version.sh -s $TYPE -m true
|
||||||
|
|
||||||
|
- name: Manage Outline release document
|
||||||
|
id: outline
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
env:
|
||||||
|
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||||
|
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||||
|
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
|
||||||
|
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
|
||||||
|
const baseUrl = 'https://outline.immich.cloud';
|
||||||
|
|
||||||
|
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ parentDocumentId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!listResponse.ok) {
|
||||||
|
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listData = await listResponse.json();
|
||||||
|
const allDocuments = listData.data || [];
|
||||||
|
|
||||||
|
const document = allDocuments.find(doc => doc.title === 'next');
|
||||||
|
|
||||||
|
let documentId;
|
||||||
|
let documentUrl;
|
||||||
|
let documentText;
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
// Create new document
|
||||||
|
console.log('No existing document found. Creating new one...');
|
||||||
|
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
|
||||||
|
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: 'next',
|
||||||
|
text: notesTmpl,
|
||||||
|
collectionId: collectionId,
|
||||||
|
parentDocumentId: parentDocumentId,
|
||||||
|
publish: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResponse.ok) {
|
||||||
|
throw new Error(`Failed to create document: ${createResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createData = await createResponse.json();
|
||||||
|
documentId = createData.data.id;
|
||||||
|
const urlId = createData.data.urlId;
|
||||||
|
documentUrl = `${baseUrl}/doc/next-${urlId}`;
|
||||||
|
documentText = createData.data.text || '';
|
||||||
|
console.log(`Created new document: ${documentUrl}`);
|
||||||
|
} else {
|
||||||
|
documentId = document.id;
|
||||||
|
const docPath = document.url;
|
||||||
|
documentUrl = `${baseUrl}${docPath}`;
|
||||||
|
documentText = document.text || '';
|
||||||
|
console.log(`Found existing document: ${documentUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate GitHub release notes
|
||||||
|
console.log('Generating GitHub release notes...');
|
||||||
|
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
tag_name: `${process.env.NEXT_VERSION}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine the content
|
||||||
|
const changelog = `
|
||||||
|
# ${process.env.NEXT_VERSION}
|
||||||
|
|
||||||
|
${documentText}
|
||||||
|
|
||||||
|
${releaseNotesResponse.data.body}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
|
||||||
|
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
|
||||||
|
|
||||||
|
core.setOutput('document_url', documentUrl);
|
||||||
|
|
||||||
|
- name: Create PR
|
||||||
|
id: create-pr
|
||||||
|
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||||
|
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||||
|
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
|
||||||
|
labels: 'changelog:skip'
|
||||||
|
branch: 'release/next'
|
||||||
|
draft: true
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
name: release.yml
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
paths:
|
||||||
|
- CHANGELOG.md
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Maybe double check PR source branch?
|
||||||
|
|
||||||
|
merge_translations:
|
||||||
|
uses: ./.github/workflows/merge-translations.yml
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
secrets:
|
||||||
|
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
|
||||||
|
|
||||||
|
build_mobile:
|
||||||
|
uses: ./.github/workflows/build-mobile.yml
|
||||||
|
needs: merge_translations
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
secrets:
|
||||||
|
KEY_JKS: ${{ secrets.KEY_JKS }}
|
||||||
|
ALIAS: ${{ secrets.ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||||
|
# iOS secrets
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||||
|
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||||
|
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||||
|
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||||
|
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
|
||||||
|
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
||||||
|
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
||||||
|
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
environment: production
|
||||||
|
|
||||||
|
prepare_release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build_mobile
|
||||||
|
permissions:
|
||||||
|
actions: read # To download the app artifact
|
||||||
|
steps:
|
||||||
|
- name: Generate a token
|
||||||
|
id: generate-token
|
||||||
|
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
persist-credentials: false
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Extract changelog
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
|
||||||
|
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
|
||||||
|
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
|
||||||
|
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Download APK
|
||||||
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
|
with:
|
||||||
|
name: release-apk-signed
|
||||||
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Create draft release
|
||||||
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.version.outputs.result }}
|
||||||
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
body_path: ${{ steps.changelog.outputs.path }}
|
||||||
|
draft: true
|
||||||
|
files: |
|
||||||
|
docker/docker-compose.yml
|
||||||
|
docker/docker-compose.rootless.yml
|
||||||
|
docker/example.env
|
||||||
|
docker/hwaccel.ml.yml
|
||||||
|
docker/hwaccel.transcoding.yml
|
||||||
|
docker/prometheus.yml
|
||||||
|
*.apk
|
||||||
|
|
||||||
|
- name: Rename Outline document
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||||
|
VERSION: ${{ steps.changelog.outputs.version }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||||
|
const version = process.env.VERSION;
|
||||||
|
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
|
||||||
|
const baseUrl = 'https://outline.immich.cloud';
|
||||||
|
|
||||||
|
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ parentDocumentId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!listResponse.ok) {
|
||||||
|
throw new Error(`Outline list failed: ${listResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listData = await listResponse.json();
|
||||||
|
const allDocuments = listData.data || [];
|
||||||
|
const document = allDocuments.find(doc => doc.title === 'next');
|
||||||
|
|
||||||
|
if (document) {
|
||||||
|
console.log(`Found document 'next', renaming to '${version}'...`);
|
||||||
|
|
||||||
|
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${outlineKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: document.id,
|
||||||
|
title: version
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateResponse.ok) {
|
||||||
|
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No document titled "next" found to rename');
|
||||||
|
}
|
||||||
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.
|
||||||
|
|||||||
+5
-5
@@ -20,8 +20,8 @@
|
|||||||
"@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.11.0",
|
"@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",
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
"prepack": "pnpm run build",
|
"prepack": "pnpm 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:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
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:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
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:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
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:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
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]
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ The default value is `ultrafast`.
|
|||||||
|
|
||||||
### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}
|
### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}
|
||||||
|
|
||||||
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`.
|
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `libopus`.
|
||||||
|
|
||||||
The default value is `aac`.
|
The default value is `aac`.
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ The default configuration looks like this:
|
|||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
"accel": "disabled",
|
"accel": "disabled",
|
||||||
"accelDecode": false,
|
"accelDecode": false,
|
||||||
"acceptedAudioCodecs": ["aac", "mp3", "opus"],
|
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
|
||||||
"acceptedContainers": ["mov", "ogg", "webm"],
|
"acceptedContainers": ["mov", "ogg", "webm"],
|
||||||
"acceptedVideoCodecs": ["h264"],
|
"acceptedVideoCodecs": ["h264"],
|
||||||
"bframes": -1,
|
"bframes": -1,
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
"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": "pnpm run copy:openapi && docusaurus build",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export enum OAuthClient {
|
|||||||
export enum OAuthUser {
|
export enum OAuthUser {
|
||||||
NO_EMAIL = 'no-email',
|
NO_EMAIL = 'no-email',
|
||||||
NO_NAME = 'no-name',
|
NO_NAME = 'no-name',
|
||||||
ID_TOKEN_CLAIMS = 'id-token-claims',
|
|
||||||
WITH_QUOTA = 'with-quota',
|
WITH_QUOTA = 'with-quota',
|
||||||
WITH_USERNAME = 'with-username',
|
WITH_USERNAME = 'with-username',
|
||||||
WITH_ROLE = 'with-role',
|
WITH_ROLE = 'with-role',
|
||||||
@@ -53,25 +52,12 @@ const withDefaultClaims = (sub: string) => ({
|
|||||||
email_verified: true,
|
email_verified: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getClaims = (sub: string, use?: string) => {
|
const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
|
||||||
if (sub === OAuthUser.ID_TOKEN_CLAIMS) {
|
|
||||||
return {
|
|
||||||
sub,
|
|
||||||
email: `oauth-${sub}@immich.app`,
|
|
||||||
email_verified: true,
|
|
||||||
name: use === 'id_token' ? 'ID Token User' : 'Userinfo User',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||||
|
|
||||||
const redirectUris = [
|
const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect'];
|
||||||
'http://127.0.0.1:2285/auth/login',
|
|
||||||
'https://photos.immich.app/oauth/mobile-redirect',
|
|
||||||
];
|
|
||||||
const port = 2286;
|
const port = 2286;
|
||||||
const host = '0.0.0.0';
|
const host = '0.0.0.0';
|
||||||
const oidc = new Provider(`http://${host}:${port}`, {
|
const oidc = new Provider(`http://${host}:${port}`, {
|
||||||
@@ -80,10 +66,7 @@ const setup = async () => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
ctx.body = 'Internal Server Error';
|
ctx.body = 'Internal Server Error';
|
||||||
},
|
},
|
||||||
findAccount: (ctx, sub) => ({
|
findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }),
|
||||||
accountId: sub,
|
|
||||||
claims: (use) => getClaims(sub, use),
|
|
||||||
}),
|
|
||||||
scopes: ['openid', 'email', 'profile'],
|
scopes: ['openid', 'email', 'profile'],
|
||||||
claims: {
|
claims: {
|
||||||
openid: ['sub'],
|
openid: ['sub'],
|
||||||
@@ -111,7 +94,6 @@ const setup = async () => {
|
|||||||
state: 'oidc.state',
|
state: 'oidc.state',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
conformIdTokenClaims: false,
|
|
||||||
pkce: {
|
pkce: {
|
||||||
required: () => false,
|
required: () => false,
|
||||||
},
|
},
|
||||||
@@ -143,10 +125,7 @@ const setup = async () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const onStart = () =>
|
const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
|
||||||
console.log(
|
|
||||||
`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`,
|
|
||||||
);
|
|
||||||
const app = oidc.listen(port, host, onStart);
|
const app = oidc.listen(port, host, onStart);
|
||||||
return () => app.close();
|
return () => app.close();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ services:
|
|||||||
immich-server:
|
immich-server:
|
||||||
container_name: immich-e2e-server
|
container_name: immich-e2e-server
|
||||||
image: immich-server:latest
|
image: immich-server:latest
|
||||||
|
shm_size: 128mb
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
@@ -44,7 +45,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich-e2e-redis
|
container_name: immich-e2e-redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -14,8 +14,8 @@
|
|||||||
"start:web": "pnpm exec playwright test --ui --project=web",
|
"start:web": "pnpm exec playwright test --ui --project=web",
|
||||||
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
|
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
|
||||||
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
|
"start:web:ui": "pnpm exec 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": "pnpm run lint --fix",
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit"
|
||||||
@@ -27,12 +27,12 @@
|
|||||||
"@eslint/js": "^10.0.0",
|
"@eslint/js": "^10.0.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.11.0",
|
"@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",
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -380,23 +380,4 @@ describe(`/oauth`, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('idTokenClaims', () => {
|
|
||||||
it('should use claims from the ID token if IDP includes them', async () => {
|
|
||||||
await setupOAuth(admin.accessToken, {
|
|
||||||
enabled: true,
|
|
||||||
clientId: OAuthClient.DEFAULT,
|
|
||||||
clientSecret: OAuthClient.DEFAULT,
|
|
||||||
});
|
|
||||||
const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS);
|
|
||||||
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
|
||||||
expect(status).toBe(201);
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
accessToken: expect.any(String),
|
|
||||||
name: 'ID Token User',
|
|
||||||
userEmail: 'oauth-id-token-claims@immich.app',
|
|
||||||
userId: expect.any(String),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -438,16 +438,6 @@ describe('/shared-links', () => {
|
|||||||
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
|
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject guests removing assets from an individual shared link', async () => {
|
|
||||||
const { status, body } = await request(app)
|
|
||||||
.delete(`/shared-links/${linkWithAssets.id}/assets`)
|
|
||||||
.query({ key: linkWithAssets.key })
|
|
||||||
.send({ assetIds: [asset1.id] });
|
|
||||||
|
|
||||||
expect(status).toBe(403);
|
|
||||||
expect(body).toEqual(errorDto.forbidden);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove assets from a shared link (individual)', async () => {
|
it('should remove assets from a shared link (individual)', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.delete(`/shared-links/${linkWithAssets.id}/assets`)
|
.delete(`/shared-links/${linkWithAssets.id}/assets`)
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||||
import { expect, test } from '@playwright/test';
|
import { Page, expect, test } from '@playwright/test';
|
||||||
import type { Socket } from 'socket.io-client';
|
|
||||||
import { utils } from 'src/utils';
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
|
function imageLocator(page: Page) {
|
||||||
|
return page.getByAltText('Image taken').locator('visible=true');
|
||||||
|
}
|
||||||
test.describe('Photo Viewer', () => {
|
test.describe('Photo Viewer', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
let asset: AssetMediaResponseDto;
|
let asset: AssetMediaResponseDto;
|
||||||
let rawAsset: AssetMediaResponseDto;
|
let rawAsset: AssetMediaResponseDto;
|
||||||
let websocket: Socket;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
utils.initSdk();
|
utils.initSdk();
|
||||||
@@ -15,11 +16,6 @@ test.describe('Photo Viewer', () => {
|
|||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup();
|
||||||
asset = await utils.createAsset(admin.accessToken);
|
asset = await utils.createAsset(admin.accessToken);
|
||||||
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
|
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
|
||||||
websocket = await utils.connectWebsocket(admin.accessToken);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(() => {
|
|
||||||
utils.disconnectWebsocket(websocket);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async ({ context, page }) => {
|
test.beforeEach(async ({ context, page }) => {
|
||||||
@@ -30,51 +26,31 @@ test.describe('Photo Viewer', () => {
|
|||||||
|
|
||||||
test('loads original photo when zoomed', async ({ page }) => {
|
test('loads original photo when zoomed', async ({ page }) => {
|
||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
const box = await imageLocator(page).boundingBox();
|
||||||
await expect(preview).toHaveAttribute('src', /.+/);
|
expect(box).toBeTruthy();
|
||||||
|
const { x, y, width, height } = box!;
|
||||||
const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));
|
await page.mouse.move(x + width / 2, y + height / 2);
|
||||||
|
|
||||||
const { width, height } = page.viewportSize()!;
|
|
||||||
await page.mouse.move(width / 2, height / 2);
|
|
||||||
await page.mouse.wheel(0, -1);
|
await page.mouse.wheel(0, -1);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
|
||||||
await originalResponse;
|
|
||||||
|
|
||||||
const original = page.getByTestId('original').filter({ visible: true });
|
|
||||||
await expect(original).toHaveAttribute('src', /original/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||||
await page.goto(`/photos/${rawAsset.id}`);
|
await page.goto(`/photos/${rawAsset.id}`);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
const box = await imageLocator(page).boundingBox();
|
||||||
await expect(preview).toHaveAttribute('src', /.+/);
|
expect(box).toBeTruthy();
|
||||||
|
const { x, y, width, height } = box!;
|
||||||
const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));
|
await page.mouse.move(x + width / 2, y + height / 2);
|
||||||
|
|
||||||
const { width, height } = page.viewportSize()!;
|
|
||||||
await page.mouse.move(width / 2, height / 2);
|
|
||||||
await page.mouse.wheel(0, -1);
|
await page.mouse.wheel(0, -1);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
||||||
await fullsizeResponse;
|
|
||||||
|
|
||||||
const original = page.getByTestId('original').filter({ visible: true });
|
|
||||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reloads photo when checksum changes', async ({ page }) => {
|
test('reloads photo when checksum changes', async ({ page }) => {
|
||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
const initialSrc = await imageLocator(page).getAttribute('src');
|
||||||
await expect(preview).toHaveAttribute('src', /.+/);
|
|
||||||
const initialSrc = await preview.getAttribute('src');
|
|
||||||
|
|
||||||
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
|
||||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||||
await websocketEvent;
|
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
|
||||||
|
|
||||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,18 +12,15 @@ import { asBearerAuth, utils } from 'src/utils';
|
|||||||
test.describe('Shared Links', () => {
|
test.describe('Shared Links', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
let asset: AssetMediaResponseDto;
|
let asset: AssetMediaResponseDto;
|
||||||
let asset2: AssetMediaResponseDto;
|
|
||||||
let album: AlbumResponseDto;
|
let album: AlbumResponseDto;
|
||||||
let sharedLink: SharedLinkResponseDto;
|
let sharedLink: SharedLinkResponseDto;
|
||||||
let sharedLinkPassword: SharedLinkResponseDto;
|
let sharedLinkPassword: SharedLinkResponseDto;
|
||||||
let individualSharedLink: SharedLinkResponseDto;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
utils.initSdk();
|
utils.initSdk();
|
||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup();
|
||||||
asset = await utils.createAsset(admin.accessToken);
|
asset = await utils.createAsset(admin.accessToken);
|
||||||
asset2 = await utils.createAsset(admin.accessToken);
|
|
||||||
album = await createAlbum(
|
album = await createAlbum(
|
||||||
{
|
{
|
||||||
createAlbumDto: {
|
createAlbumDto: {
|
||||||
@@ -42,10 +39,6 @@ test.describe('Shared Links', () => {
|
|||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
password: 'test-password',
|
password: 'test-password',
|
||||||
});
|
});
|
||||||
individualSharedLink = await utils.createSharedLink(admin.accessToken, {
|
|
||||||
type: SharedLinkType.Individual,
|
|
||||||
assetIds: [asset.id, asset2.id],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('download from a shared link', async ({ page }) => {
|
test('download from a shared link', async ({ page }) => {
|
||||||
@@ -116,21 +109,4 @@ test.describe('Shared Links', () => {
|
|||||||
await page.waitForURL('/photos');
|
await page.waitForURL('/photos');
|
||||||
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
|
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('owner can remove assets from an individual shared link', async ({ context, page }) => {
|
|
||||||
await utils.setAuthCookies(context, admin.accessToken);
|
|
||||||
|
|
||||||
await page.goto(`/share/${individualSharedLink.key}`);
|
|
||||||
await page.locator(`[data-asset="${asset.id}"]`).waitFor();
|
|
||||||
await expect(page.locator(`[data-asset]`)).toHaveCount(2);
|
|
||||||
|
|
||||||
await page.locator(`[data-asset="${asset.id}"]`).hover();
|
|
||||||
await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Remove from shared link' }).click();
|
|
||||||
await page.getByRole('button', { name: 'Remove', exact: true }).click();
|
|
||||||
|
|
||||||
await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0);
|
|
||||||
await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export {
|
|||||||
toColumnarFormat,
|
toColumnarFormat,
|
||||||
} from './timeline/rest-response';
|
} from './timeline/rest-response';
|
||||||
|
|
||||||
export type { Changes, FaceData } from './timeline/rest-response';
|
export type { Changes } from './timeline/rest-response';
|
||||||
|
|
||||||
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
|
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import {
|
|||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
UserAvatarColor,
|
UserAvatarColor,
|
||||||
type AlbumResponseDto,
|
type AlbumResponseDto,
|
||||||
type AssetFaceWithoutPersonResponseDto,
|
|
||||||
type AssetResponseDto,
|
type AssetResponseDto,
|
||||||
type ExifResponseDto,
|
type ExifResponseDto,
|
||||||
type PersonWithFacesResponseDto,
|
|
||||||
type TimeBucketAssetResponseDto,
|
type TimeBucketAssetResponseDto,
|
||||||
type TimeBucketsResponseDto,
|
type TimeBucketsResponseDto,
|
||||||
type UserResponseDto,
|
type UserResponseDto,
|
||||||
@@ -286,16 +284,7 @@ const createDefaultOwner = (ownerId: string) => {
|
|||||||
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||||
* This matches the response from GET /api/assets/:id
|
* This matches the response from GET /api/assets/:id
|
||||||
*/
|
*/
|
||||||
export type FaceData = {
|
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
|
||||||
people: PersonWithFacesResponseDto[];
|
|
||||||
unassignedFaces: AssetFaceWithoutPersonResponseDto[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function toAssetResponseDto(
|
|
||||||
asset: MockTimelineAsset,
|
|
||||||
owner?: UserResponseDto,
|
|
||||||
faceData?: FaceData,
|
|
||||||
): AssetResponseDto {
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
// Default owner if not provided
|
// Default owner if not provided
|
||||||
@@ -349,8 +338,8 @@ export function toAssetResponseDto(
|
|||||||
exifInfo,
|
exifInfo,
|
||||||
livePhotoVideoId: asset.livePhotoVideoId,
|
livePhotoVideoId: asset.livePhotoVideoId,
|
||||||
tags: [],
|
tags: [],
|
||||||
people: faceData?.people ?? [],
|
people: [],
|
||||||
unassignedFaces: faceData?.unassignedFaces ?? [],
|
unassignedFaces: [],
|
||||||
stack: asset.stack,
|
stack: asset.stack,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
hasMetadata: true,
|
hasMetadata: true,
|
||||||
|
|||||||
@@ -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,209 +0,0 @@
|
|||||||
import type { AssetFaceResponseDto, AssetResponseDto, PersonWithFacesResponseDto, SourceType } from '@immich/sdk';
|
|
||||||
import { BrowserContext } from '@playwright/test';
|
|
||||||
import { type FaceData, 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),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MockFaceSpec = {
|
|
||||||
personId: string;
|
|
||||||
personName: string;
|
|
||||||
faceId: string;
|
|
||||||
boundingBoxX1: number;
|
|
||||||
boundingBoxY1: number;
|
|
||||||
boundingBoxX2: number;
|
|
||||||
boundingBoxY2: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toPersonResponseDto = (spec: MockFaceSpec) => ({
|
|
||||||
id: spec.personId,
|
|
||||||
name: spec.personName,
|
|
||||||
birthDate: null,
|
|
||||||
isHidden: false,
|
|
||||||
thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`,
|
|
||||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
const toBoundingBox = (spec: MockFaceSpec, imageWidth: number, imageHeight: number) => ({
|
|
||||||
id: spec.faceId,
|
|
||||||
imageWidth,
|
|
||||||
imageHeight,
|
|
||||||
boundingBoxX1: spec.boundingBoxX1,
|
|
||||||
boundingBoxY1: spec.boundingBoxY1,
|
|
||||||
boundingBoxX2: spec.boundingBoxX2,
|
|
||||||
boundingBoxY2: spec.boundingBoxY2,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createMockFaceData = (specs: MockFaceSpec[], imageWidth: number, imageHeight: number): FaceData => {
|
|
||||||
const people: PersonWithFacesResponseDto[] = specs.map((spec) => ({
|
|
||||||
...toPersonResponseDto(spec),
|
|
||||||
faces: [toBoundingBox(spec, imageWidth, imageHeight)],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { people, unassignedFaces: [] };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createMockAssetFaces = (
|
|
||||||
specs: MockFaceSpec[],
|
|
||||||
imageWidth: number,
|
|
||||||
imageHeight: number,
|
|
||||||
): AssetFaceResponseDto[] => {
|
|
||||||
return specs.map((spec) => ({
|
|
||||||
...toBoundingBox(spec, imageWidth, imageHeight),
|
|
||||||
person: toPersonResponseDto(spec),
|
|
||||||
sourceType: 'machine-learning' as SourceType,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setupGetFacesMockApiRoute = async (context: BrowserContext, faces: AssetFaceResponseDto[]) => {
|
|
||||||
await context.route('**/api/faces?*', async (route, request) => {
|
|
||||||
if (request.method() !== 'GET') {
|
|
||||||
return route.fallback();
|
|
||||||
}
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
json: faces,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setupFaceOverlayMockApiRoutes = async (context: BrowserContext, assetDto: AssetResponseDto) => {
|
|
||||||
await context.route('**/api/assets/*', async (route, request) => {
|
|
||||||
if (request.method() !== 'GET') {
|
|
||||||
return route.fallback();
|
|
||||||
}
|
|
||||||
const url = new URL(request.url());
|
|
||||||
const assetId = url.pathname.split('/').at(-1);
|
|
||||||
if (assetId !== assetDto.id) {
|
|
||||||
return route.fallback();
|
|
||||||
}
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
json: assetDto,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { faker } from '@faker-js/faker';
|
|
||||||
import type { AssetOcrResponseDto } from '@immich/sdk';
|
|
||||||
import { BrowserContext } from '@playwright/test';
|
|
||||||
|
|
||||||
export type MockOcrBox = {
|
|
||||||
text: string;
|
|
||||||
x1: number;
|
|
||||||
y1: number;
|
|
||||||
x2: number;
|
|
||||||
y2: number;
|
|
||||||
x3: number;
|
|
||||||
y3: number;
|
|
||||||
x4: number;
|
|
||||||
y4: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createMockOcrData = (assetId: string, boxes: MockOcrBox[]): AssetOcrResponseDto[] => {
|
|
||||||
return boxes.map((box) => ({
|
|
||||||
id: faker.string.uuid(),
|
|
||||||
assetId,
|
|
||||||
x1: box.x1,
|
|
||||||
y1: box.y1,
|
|
||||||
x2: box.x2,
|
|
||||||
y2: box.y2,
|
|
||||||
x3: box.x3,
|
|
||||||
y3: box.y3,
|
|
||||||
x4: box.x4,
|
|
||||||
y4: box.y4,
|
|
||||||
boxScore: 0.95,
|
|
||||||
textScore: 0.9,
|
|
||||||
text: box.text,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setupOcrMockApiRoutes = async (
|
|
||||||
context: BrowserContext,
|
|
||||||
ocrDataByAssetId: Map<string, AssetOcrResponseDto[]>,
|
|
||||||
) => {
|
|
||||||
await context.route('**/assets/*/ocr', async (route, request) => {
|
|
||||||
if (request.method() !== 'GET') {
|
|
||||||
return route.fallback();
|
|
||||||
}
|
|
||||||
const url = new URL(request.url());
|
|
||||||
const segments = url.pathname.split('/');
|
|
||||||
const assetIdIndex = segments.indexOf('assets') + 1;
|
|
||||||
const assetId = segments[assetIdIndex];
|
|
||||||
|
|
||||||
const ocrData = ocrDataByAssetId.get(assetId) ?? [];
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
json: ocrData,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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,86 +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`) ||
|
|
||||||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
|
|
||||||
async (route) => {
|
|
||||||
return route.fulfill({ status: 404 });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await page.waitForSelector('#immich-asset-viewer');
|
|
||||||
|
|
||||||
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first();
|
|
||||||
await expect(viewerBrokenAsset).toBeVisible();
|
|
||||||
|
|
||||||
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
|
||||||
|
|
||||||
const messageSpan = viewerBrokenAsset.locator('span');
|
|
||||||
await expect(messageSpan).toHaveClass(/text-base/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,327 +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 with valid coordinates 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);
|
|
||||||
const request = faceCreateCapture.requests[0];
|
|
||||||
expect(request.assetId).toBe(asset.id);
|
|
||||||
expect(request.personId).toBe(personToTag.id);
|
|
||||||
expect(request.x).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(request.y).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(request.width).toBeGreaterThan(0);
|
|
||||||
expect(request.height).toBeGreaterThan(0);
|
|
||||||
expect(request.x + request.width).toBeLessThanOrEqual(request.imageWidth);
|
|
||||||
expect(request.y + request.height).toBeLessThanOrEqual(request.imageHeight);
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Cancel on confirmation dialog keeps face editor open', 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('dialog')
|
|
||||||
.getByRole('button', { name: /cancel/i })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('dialog')).toBeHidden();
|
|
||||||
await expect(page.locator('#face-selector')).toBeVisible();
|
|
||||||
await expect(page.locator('#face-editor')).toBeVisible();
|
|
||||||
expect(faceCreateCapture.requests).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Clicking on face rect center does not reposition it', async ({ page }) => {
|
|
||||||
const asset = selectRandom(fixture.assets, rng);
|
|
||||||
await openFaceEditor(page, asset);
|
|
||||||
|
|
||||||
const beforeClick = await getFaceBoxRect(page);
|
|
||||||
const centerX = beforeClick.left + beforeClick.width / 2;
|
|
||||||
const centerY = beforeClick.top + beforeClick.height / 2;
|
|
||||||
|
|
||||||
await page.mouse.click(centerX, centerY);
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
const afterClick = await getFaceBoxRect(page);
|
|
||||||
expect(Math.abs(afterClick.left - beforeClick.left)).toBeLessThan(3);
|
|
||||||
expect(Math.abs(afterClick.top - beforeClick.top)).toBeLessThan(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
|
||||||
import {
|
|
||||||
createMockAssetFaces,
|
|
||||||
createMockFaceData,
|
|
||||||
createMockPeople,
|
|
||||||
type MockFaceSpec,
|
|
||||||
setupFaceEditorMockApiRoutes,
|
|
||||||
setupFaceOverlayMockApiRoutes,
|
|
||||||
setupGetFacesMockApiRoute,
|
|
||||||
} from 'src/ui/mock-network/face-editor-network';
|
|
||||||
import { assetViewerUtils } from '../timeline/utils';
|
|
||||||
import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
|
||||||
|
|
||||||
const FACE_SPECS: MockFaceSpec[] = [
|
|
||||||
{
|
|
||||||
personId: 'person-alice',
|
|
||||||
personName: 'Alice Johnson',
|
|
||||||
faceId: 'face-alice',
|
|
||||||
boundingBoxX1: 1000,
|
|
||||||
boundingBoxY1: 500,
|
|
||||||
boundingBoxX2: 1500,
|
|
||||||
boundingBoxY2: 1200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
personId: 'person-bob',
|
|
||||||
personName: 'Bob Smith',
|
|
||||||
faceId: 'face-bob',
|
|
||||||
boundingBoxX1: 2000,
|
|
||||||
boundingBoxY1: 800,
|
|
||||||
boundingBoxX2: 2400,
|
|
||||||
boundingBoxY2: 1600,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const setupFaceMocks = async (
|
|
||||||
context: import('@playwright/test').BrowserContext,
|
|
||||||
fixture: ReturnType<typeof setupAssetViewerFixture>,
|
|
||||||
) => {
|
|
||||||
const mockPeople = createMockPeople(4);
|
|
||||||
const faceData = createMockFaceData(
|
|
||||||
FACE_SPECS,
|
|
||||||
fixture.primaryAssetDto.width ?? 3000,
|
|
||||||
fixture.primaryAssetDto.height ?? 4000,
|
|
||||||
);
|
|
||||||
const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData);
|
|
||||||
await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces);
|
|
||||||
await setupFaceEditorMockApiRoutes(context, mockPeople, { requests: [] });
|
|
||||||
};
|
|
||||||
|
|
||||||
test.describe('face overlay bounding boxes', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(901);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
await setupFaceMocks(context, fixture);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('face overlay divs render with correct aria labels', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
|
||||||
const bobOverlay = page.getByLabel('Person: Bob Smith');
|
|
||||||
|
|
||||||
await expect(aliceOverlay).toBeVisible();
|
|
||||||
await expect(bobOverlay).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('face overlay shows border on hover', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
|
||||||
await expect(aliceOverlay).toBeVisible();
|
|
||||||
|
|
||||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
|
||||||
await expect(activeBorder).toHaveCount(0);
|
|
||||||
|
|
||||||
await aliceOverlay.hover();
|
|
||||||
await expect(activeBorder).toHaveCount(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('face name tooltip appears on hover', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
|
||||||
await expect(aliceOverlay).toBeVisible();
|
|
||||||
|
|
||||||
await aliceOverlay.hover();
|
|
||||||
|
|
||||||
const nameTooltip = page.locator('[data-viewer-content]').getByText('Alice Johnson');
|
|
||||||
await expect(nameTooltip).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('face overlays hidden in face edit mode', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
|
||||||
await expect(aliceOverlay).toBeVisible();
|
|
||||||
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
await page.getByLabel('Tag people').click();
|
|
||||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
|
||||||
|
|
||||||
await expect(aliceOverlay).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('face overlay hover works after exiting face edit mode', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
|
||||||
await expect(aliceOverlay).toBeVisible();
|
|
||||||
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
await page.getByLabel('Tag people').click();
|
|
||||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
|
||||||
await expect(aliceOverlay).toBeHidden();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /cancel/i }).click();
|
|
||||||
await expect(page.locator('#face-selector')).toBeHidden();
|
|
||||||
|
|
||||||
await expect(aliceOverlay).toBeVisible();
|
|
||||||
|
|
||||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
|
||||||
await expect(activeBorder).toHaveCount(0);
|
|
||||||
await aliceOverlay.hover();
|
|
||||||
await expect(activeBorder).toHaveCount(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('zoom and face editor interaction', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(902);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
await setupFaceMocks(context, fixture);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('zoom is preserved when entering face edit mode', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const { width, height } = page.viewportSize()!;
|
|
||||||
await page.mouse.move(width / 2, height / 2);
|
|
||||||
await page.mouse.wheel(0, -1);
|
|
||||||
|
|
||||||
const imgLocator = page.locator('[data-viewer-content] img[data-testid="preview"]');
|
|
||||||
await expect(async () => {
|
|
||||||
const transform = await imgLocator.evaluate((element) => {
|
|
||||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
|
||||||
});
|
|
||||||
expect(transform).not.toBe('none');
|
|
||||||
expect(transform).not.toBe('');
|
|
||||||
}).toPass({ timeout: 2000 });
|
|
||||||
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
await page.getByLabel('Tag people').click();
|
|
||||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
|
||||||
|
|
||||||
await expect(page.locator('#face-editor')).toBeVisible();
|
|
||||||
|
|
||||||
const afterTransform = await imgLocator.evaluate((element) => {
|
|
||||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
|
||||||
});
|
|
||||||
expect(afterTransform).not.toBe('none');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('face overlay via detail panel interaction', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(903);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
await setupFaceMocks(context, fixture);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('hovering person in detail panel shows face overlay border', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
|
|
||||||
const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' });
|
|
||||||
await expect(personLink).toBeVisible();
|
|
||||||
|
|
||||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
|
||||||
await expect(activeBorder).toHaveCount(0);
|
|
||||||
|
|
||||||
await personLink.hover();
|
|
||||||
await expect(activeBorder).toHaveCount(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('touch pointer on person in detail panel shows face overlay border', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
|
|
||||||
const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' });
|
|
||||||
await expect(personLink).toBeVisible();
|
|
||||||
|
|
||||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
|
||||||
await expect(activeBorder).toHaveCount(0);
|
|
||||||
|
|
||||||
// Simulate a touch-type pointerover (the fix changed from onmouseover to onpointerover,
|
|
||||||
// which fires for touch pointers unlike mouseover)
|
|
||||||
await personLink.dispatchEvent('pointerover', { pointerType: 'touch' });
|
|
||||||
await expect(activeBorder).toHaveCount(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('hovering person in detail panel works after exiting face edit mode', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
await page.getByLabel('Tag people').click();
|
|
||||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /cancel/i }).click();
|
|
||||||
await expect(page.locator('#face-selector')).toBeHidden();
|
|
||||||
|
|
||||||
const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' });
|
|
||||||
await expect(personLink).toBeVisible();
|
|
||||||
|
|
||||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
|
||||||
await personLink.hover();
|
|
||||||
await expect(activeBorder).toHaveCount(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('face overlay via edit faces side panel', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(904);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
await setupFaceMocks(context, fixture);
|
|
||||||
|
|
||||||
const assetFaces = createMockAssetFaces(
|
|
||||||
FACE_SPECS,
|
|
||||||
fixture.primaryAssetDto.width ?? 3000,
|
|
||||||
fixture.primaryAssetDto.height ?? 4000,
|
|
||||||
);
|
|
||||||
await setupGetFacesMockApiRoute(context, assetFaces);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('hovering person in edit faces panel shows face overlay border', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
await page.getByLabel('Edit people').click();
|
|
||||||
|
|
||||||
const faceThumbnail = page.getByTestId('face-thumbnail').first();
|
|
||||||
await expect(faceThumbnail).toBeVisible();
|
|
||||||
|
|
||||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
|
||||||
await expect(activeBorder).toHaveCount(0);
|
|
||||||
|
|
||||||
await faceThumbnail.hover();
|
|
||||||
await expect(activeBorder).toHaveCount(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
import type { AssetOcrResponseDto, 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 { createMockOcrData, setupOcrMockApiRoutes } from 'src/ui/mock-network/ocr-network';
|
|
||||||
import { assetViewerUtils } from '../timeline/utils';
|
|
||||||
import { setupAssetViewerFixture } from './utils';
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
|
||||||
|
|
||||||
const PRIMARY_OCR_BOXES = [
|
|
||||||
{ text: 'Hello World', x1: 0.1, y1: 0.1, x2: 0.4, y2: 0.1, x3: 0.4, y3: 0.15, x4: 0.1, y4: 0.15 },
|
|
||||||
{ text: 'Immich Photo', x1: 0.2, y1: 0.3, x2: 0.6, y2: 0.3, x3: 0.6, y3: 0.36, x4: 0.2, y4: 0.36 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SECONDARY_OCR_BOXES = [
|
|
||||||
{ text: 'Second Asset Text', x1: 0.15, y1: 0.2, x2: 0.55, y2: 0.2, x3: 0.55, y3: 0.26, x4: 0.15, y4: 0.26 },
|
|
||||||
];
|
|
||||||
|
|
||||||
test.describe('OCR bounding boxes', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(920);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
|
||||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
|
||||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
|
||||||
]);
|
|
||||||
|
|
||||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('OCR bounding boxes appear when clicking OCR button', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const ocrButton = page.getByLabel('Text recognition');
|
|
||||||
await expect(ocrButton).toBeVisible();
|
|
||||||
await ocrButton.click();
|
|
||||||
|
|
||||||
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
|
|
||||||
await expect(ocrBoxes).toHaveCount(2);
|
|
||||||
|
|
||||||
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
|
|
||||||
await expect(ocrBoxes.nth(1)).toContainText('Immich Photo');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('OCR bounding boxes toggle off on second click', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const ocrButton = page.getByLabel('Text recognition');
|
|
||||||
await ocrButton.click();
|
|
||||||
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]').first()).toBeVisible();
|
|
||||||
|
|
||||||
await ocrButton.click();
|
|
||||||
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]')).toHaveCount(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('OCR with stacked assets', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(921);
|
|
||||||
let mockStack: MockStack;
|
|
||||||
let primaryAssetDto: AssetResponseDto;
|
|
||||||
let secondAssetDto: AssetResponseDto;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
|
||||||
secondAssetDto = createMockStackAsset(fixture.adminUserId);
|
|
||||||
secondAssetDto.originalFileName = 'second-ocr-asset.jpg';
|
|
||||||
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
|
||||||
|
|
||||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
|
||||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
|
||||||
[secondAssetDto.id, createMockOcrData(secondAssetDto.id, SECONDARY_OCR_BOXES)],
|
|
||||||
]);
|
|
||||||
|
|
||||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('different OCR boxes shown for different stacked assets', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const ocrButton = page.getByLabel('Text recognition');
|
|
||||||
await expect(ocrButton).toBeVisible();
|
|
||||||
await ocrButton.click();
|
|
||||||
|
|
||||||
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
|
|
||||||
await expect(ocrBoxes).toHaveCount(2);
|
|
||||||
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
|
|
||||||
|
|
||||||
const stackThumbnails = page.locator('#stack-slideshow [data-asset]');
|
|
||||||
await expect(stackThumbnails).toHaveCount(2);
|
|
||||||
await stackThumbnails.nth(1).click();
|
|
||||||
|
|
||||||
// refreshOcr() clears showOverlay when switching assets, so re-enable it
|
|
||||||
await expect(ocrBoxes).toHaveCount(0);
|
|
||||||
await expect(ocrButton).toBeVisible();
|
|
||||||
await ocrButton.click();
|
|
||||||
|
|
||||||
await expect(ocrBoxes).toHaveCount(1);
|
|
||||||
await expect(ocrBoxes.first()).toContainText('Second Asset Text');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('OCR boxes and zoom', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(922);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
|
||||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
|
||||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
|
||||||
]);
|
|
||||||
|
|
||||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('OCR boxes scale with zoom', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
const ocrButton = page.getByLabel('Text recognition');
|
|
||||||
await expect(ocrButton).toBeVisible();
|
|
||||||
await ocrButton.click();
|
|
||||||
|
|
||||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
|
||||||
await expect(ocrBox).toBeVisible();
|
|
||||||
|
|
||||||
const initialBox = await ocrBox.boundingBox();
|
|
||||||
expect(initialBox).toBeTruthy();
|
|
||||||
|
|
||||||
const { width, height } = page.viewportSize()!;
|
|
||||||
await page.mouse.move(width / 2, height / 2);
|
|
||||||
await page.mouse.wheel(0, -3);
|
|
||||||
|
|
||||||
await expect(async () => {
|
|
||||||
const zoomedBox = await ocrBox.boundingBox();
|
|
||||||
expect(zoomedBox).toBeTruthy();
|
|
||||||
expect(zoomedBox!.width).toBeGreaterThan(initialBox!.width);
|
|
||||||
expect(zoomedBox!.height).toBeGreaterThan(initialBox!.height);
|
|
||||||
}).toPass({ timeout: 2000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('OCR text interaction', () => {
|
|
||||||
const fixture = setupAssetViewerFixture(923);
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
|
||||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
|
||||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
|
||||||
]);
|
|
||||||
|
|
||||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('OCR text box has data-overlay-interactive attribute', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
await page.getByLabel('Text recognition').click();
|
|
||||||
|
|
||||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
|
||||||
await expect(ocrBox).toBeVisible();
|
|
||||||
await expect(ocrBox).toHaveAttribute('data-overlay-interactive');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('OCR text box receives focus on click', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
await page.getByLabel('Text recognition').click();
|
|
||||||
|
|
||||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
|
||||||
await expect(ocrBox).toBeVisible();
|
|
||||||
|
|
||||||
await ocrBox.click();
|
|
||||||
await expect(ocrBox).toBeFocused();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dragging on OCR text box does not trigger image pan', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
await page.getByLabel('Text recognition').click();
|
|
||||||
|
|
||||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
|
||||||
await expect(ocrBox).toBeVisible();
|
|
||||||
|
|
||||||
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
|
|
||||||
const initialTransform = await imgLocator.evaluate((element) => {
|
|
||||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
|
||||||
});
|
|
||||||
|
|
||||||
const box = await ocrBox.boundingBox();
|
|
||||||
expect(box).toBeTruthy();
|
|
||||||
const centerX = box!.x + box!.width / 2;
|
|
||||||
const centerY = box!.y + box!.height / 2;
|
|
||||||
|
|
||||||
await page.mouse.move(centerX, centerY);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(centerX + 50, centerY + 30, { steps: 5 });
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
const afterTransform = await imgLocator.evaluate((element) => {
|
|
||||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
|
||||||
});
|
|
||||||
expect(afterTransform).toBe(initialTransform);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('split touch gesture across zoom container does not trigger zoom', async ({ page }) => {
|
|
||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
|
||||||
|
|
||||||
await page.getByLabel('Text recognition').click();
|
|
||||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
|
||||||
await expect(ocrBox).toBeVisible();
|
|
||||||
|
|
||||||
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
|
|
||||||
const initialTransform = await imgLocator.evaluate((element) => {
|
|
||||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
|
||||||
});
|
|
||||||
|
|
||||||
const viewerContent = page.locator('[data-viewer-content]');
|
|
||||||
const viewerBox = await viewerContent.boundingBox();
|
|
||||||
expect(viewerBox).toBeTruthy();
|
|
||||||
|
|
||||||
// Dispatch a synthetic split gesture: one touch inside the viewer, one outside
|
|
||||||
await page.evaluate(
|
|
||||||
({ viewerCenterX, viewerCenterY, outsideY }) => {
|
|
||||||
const viewer = document.querySelector('[data-viewer-content]');
|
|
||||||
if (!viewer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createTouch = (id: number, x: number, y: number) => {
|
|
||||||
return new Touch({
|
|
||||||
identifier: id,
|
|
||||||
target: viewer,
|
|
||||||
clientX: x,
|
|
||||||
clientY: y,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const insideTouch = createTouch(0, viewerCenterX, viewerCenterY);
|
|
||||||
const outsideTouch = createTouch(1, viewerCenterX, outsideY);
|
|
||||||
|
|
||||||
const touchStartEvent = new TouchEvent('touchstart', {
|
|
||||||
touches: [insideTouch, outsideTouch],
|
|
||||||
targetTouches: [insideTouch],
|
|
||||||
changedTouches: [insideTouch, outsideTouch],
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const touchMoveEvent = new TouchEvent('touchmove', {
|
|
||||||
touches: [createTouch(0, viewerCenterX, viewerCenterY - 30), createTouch(1, viewerCenterX, outsideY + 30)],
|
|
||||||
targetTouches: [createTouch(0, viewerCenterX, viewerCenterY - 30)],
|
|
||||||
changedTouches: [
|
|
||||||
createTouch(0, viewerCenterX, viewerCenterY - 30),
|
|
||||||
createTouch(1, viewerCenterX, outsideY + 30),
|
|
||||||
],
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const touchEndEvent = new TouchEvent('touchend', {
|
|
||||||
touches: [],
|
|
||||||
targetTouches: [],
|
|
||||||
changedTouches: [insideTouch, outsideTouch],
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
viewer.dispatchEvent(touchStartEvent);
|
|
||||||
viewer.dispatchEvent(touchMoveEvent);
|
|
||||||
viewer.dispatchEvent(touchEndEvent);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
viewerCenterX: viewerBox!.x + viewerBox!.width / 2,
|
|
||||||
viewerCenterY: viewerBox!.y + viewerBox!.height / 2,
|
|
||||||
outsideY: 10, // near the top of the page, outside the viewer
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const afterTransform = await imgLocator.evaluate((element) => {
|
|
||||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
|
||||||
});
|
|
||||||
expect(afterTransform).toBe(initialTransform);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
generateTimelineData,
|
generateTimelineData,
|
||||||
TimelineAssetConfig,
|
TimelineAssetConfig,
|
||||||
TimelineData,
|
TimelineData,
|
||||||
toAssetResponseDto,
|
|
||||||
} from 'src/ui/generators/timeline';
|
} from 'src/ui/generators/timeline';
|
||||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||||
@@ -31,10 +30,6 @@ test.describe('search gallery-viewer', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
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',
|
|
||||||
);
|
|
||||||
adminUserId = faker.string.uuid();
|
adminUserId = faker.string.uuid();
|
||||||
testContext.adminId = adminUserId;
|
testContext.adminId = adminUserId;
|
||||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||||
@@ -49,10 +44,7 @@ test.describe('search gallery-viewer', () => {
|
|||||||
|
|
||||||
await context.route('**/api/search/metadata', async (route, request) => {
|
await context.route('**/api/search/metadata', async (route, request) => {
|
||||||
if (request.method() === 'POST') {
|
if (request.method() === 'POST') {
|
||||||
const searchAssets = assets
|
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
|
||||||
.slice(0, 5)
|
|
||||||
.filter((asset) => !changes.assetDeletions.includes(asset.id))
|
|
||||||
.map((asset) => toAssetResponseDto(asset));
|
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
|
|||||||
@@ -164,8 +164,12 @@ export const assetViewerUtils = {
|
|||||||
},
|
},
|
||||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||||
await page
|
await page
|
||||||
.locator(`img[data-testid="preview"][src*="${asset.id}"]`)
|
.locator(
|
||||||
.or(page.locator(`video[poster*="${asset.id}"]`))
|
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
||||||
|
)
|
||||||
|
.or(
|
||||||
|
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
||||||
|
)
|
||||||
.waitFor();
|
.waitFor();
|
||||||
},
|
},
|
||||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||||
@@ -211,9 +215,8 @@ export const pageUtils = {
|
|||||||
await page.getByText('Confirm').click();
|
await page.getByText('Confirm').click();
|
||||||
},
|
},
|
||||||
async selectDay(page: Page, day: string) {
|
async selectDay(page: Page, day: string) {
|
||||||
const section = page.getByTitle(day).locator('xpath=ancestor::section[@data-group]');
|
await page.getByTitle(day).hover();
|
||||||
await section.hover();
|
await page.locator('[data-group] .w-8').click();
|
||||||
await section.locator('.w-8').click();
|
|
||||||
},
|
},
|
||||||
async pauseTestDebug() {
|
async pauseTestDebug() {
|
||||||
console.log('NOTE: pausing test indefinately for debug');
|
console.log('NOTE: pausing test indefinately for debug');
|
||||||
|
|||||||
+29
-40
@@ -177,51 +177,40 @@ export const utils = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
resetDatabase: async (tables?: string[]) => {
|
resetDatabase: async (tables?: string[]) => {
|
||||||
client = await utils.connectDatabase();
|
try {
|
||||||
|
client = await utils.connectDatabase();
|
||||||
|
|
||||||
tables = tables || [
|
tables = tables || [
|
||||||
// TODO e2e test for deleting a stack, since it is quite complex
|
// TODO e2e test for deleting a stack, since it is quite complex
|
||||||
'stack',
|
'stack',
|
||||||
'library',
|
'library',
|
||||||
'shared_link',
|
'shared_link',
|
||||||
'person',
|
'person',
|
||||||
'album',
|
'album',
|
||||||
'asset',
|
'asset',
|
||||||
'asset_face',
|
'asset_face',
|
||||||
'activity',
|
'activity',
|
||||||
'api_key',
|
'api_key',
|
||||||
'session',
|
'session',
|
||||||
'user',
|
'user',
|
||||||
'system_metadata',
|
'system_metadata',
|
||||||
'tag',
|
'tag',
|
||||||
];
|
];
|
||||||
|
|
||||||
const truncateTables = tables.filter((table) => table !== 'system_metadata');
|
const sql: string[] = [];
|
||||||
const sql: string[] = [];
|
|
||||||
|
|
||||||
if (truncateTables.length > 0) {
|
for (const table of tables) {
|
||||||
sql.push(`TRUNCATE "${truncateTables.join('", "')}" CASCADE;`);
|
if (table === 'system_metadata') {
|
||||||
}
|
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
|
||||||
|
} else {
|
||||||
if (tables.includes('system_metadata')) {
|
sql.push(`DELETE FROM "${table}" CASCADE;`);
|
||||||
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = sql.join('\n');
|
|
||||||
const maxRetries = 3;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
await client.query(query);
|
|
||||||
return;
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error?.code === '40P01' && attempt < maxRetries) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
console.error('Failed to reset database', error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await client.query(sql.join('\n'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset database', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
import { faker } from '@faker-js/faker';
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
Changes,
|
|
||||||
createDefaultTimelineConfig,
|
|
||||||
generateTimelineData,
|
|
||||||
SeededRandom,
|
|
||||||
selectRandom,
|
|
||||||
TimelineAssetConfig,
|
|
||||||
TimelineData,
|
|
||||||
} 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 { assetViewerUtils } from 'src/ui/specs/timeline/utils';
|
|
||||||
import { utils } from 'src/utils';
|
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
|
||||||
test.describe('asset-viewer', () => {
|
|
||||||
const rng = new SeededRandom(529);
|
|
||||||
let adminUserId: string;
|
|
||||||
let timelineRestData: TimelineData;
|
|
||||||
const assets: TimelineAssetConfig[] = [];
|
|
||||||
const yearMonths: string[] = [];
|
|
||||||
const testContext = new TimelineTestContext();
|
|
||||||
const changes: Changes = {
|
|
||||||
albumAdditions: [],
|
|
||||||
assetDeletions: [],
|
|
||||||
assetArchivals: [],
|
|
||||||
assetFavorites: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
utils.initSdk();
|
|
||||||
adminUserId = faker.string.uuid();
|
|
||||||
testContext.adminId = adminUserId;
|
|
||||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
|
||||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
|
||||||
assets.push(...timeBucket);
|
|
||||||
}
|
|
||||||
for (const yearMonth of timelineRestData.buckets.keys()) {
|
|
||||||
const [year, month] = yearMonth.split('-');
|
|
||||||
yearMonths.push(`${year}-${Number(month)}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async ({ context }) => {
|
|
||||||
await setupBaseMockApiRoutes(context, adminUserId);
|
|
||||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(() => {
|
|
||||||
testContext.slowBucket = false;
|
|
||||||
changes.albumAdditions = [];
|
|
||||||
changes.assetDeletions = [];
|
|
||||||
changes.assetArchivals = [];
|
|
||||||
changes.assetFavorites = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('/photos/:id', () => {
|
|
||||||
test('Navigate to next asset via button', async ({ page }) => {
|
|
||||||
const asset = selectRandom(assets, rng);
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
await page.goto(`/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
|
||||||
|
|
||||||
await page.getByLabel('View next asset').click();
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
|
||||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Navigate to previous asset via button', async ({ page }) => {
|
|
||||||
const asset = selectRandom(assets, rng);
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
await page.goto(`/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
|
||||||
|
|
||||||
await page.getByLabel('View previous asset').click();
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
|
||||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
|
|
||||||
const asset = selectRandom(assets, rng);
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
await page.goto(`/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
|
||||||
|
|
||||||
await page.getByTestId('next-asset').waitFor();
|
|
||||||
await page.keyboard.press('ArrowRight');
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
|
||||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
|
|
||||||
const asset = selectRandom(assets, rng);
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
await page.goto(`/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
|
||||||
|
|
||||||
await page.getByTestId('previous-asset').waitFor();
|
|
||||||
await page.keyboard.press('ArrowLeft');
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
|
||||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Navigate forward 5 times via button', async ({ page }) => {
|
|
||||||
const asset = selectRandom(assets, rng);
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
await page.goto(`/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
await page.getByLabel('View next asset').click();
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
|
||||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Navigate backward 5 times via button', async ({ page }) => {
|
|
||||||
const asset = selectRandom(assets, rng);
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
await page.goto(`/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
await page.getByLabel('View previous asset').click();
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
|
|
||||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Navigate forward then backward via keyboard', async ({ page }) => {
|
|
||||||
const asset = selectRandom(assets, rng);
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
await page.goto(`/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
|
|
||||||
// Navigate forward 3 times
|
|
||||||
for (let i = 1; i <= 3; i++) {
|
|
||||||
await page.getByTestId('next-asset').waitFor();
|
|
||||||
await page.keyboard.press('ArrowRight');
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate backward 3 times to return to original
|
|
||||||
for (let i = 2; i >= 0; i--) {
|
|
||||||
await page.getByTestId('previous-asset').waitFor();
|
|
||||||
await page.keyboard.press('ArrowLeft');
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify we're back at the original asset
|
|
||||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Verify no next button on last asset', async ({ page }) => {
|
|
||||||
const lastAsset = assets.at(-1)!;
|
|
||||||
await page.goto(`/photos/${lastAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
|
||||||
|
|
||||||
// Verify next button doesn't exist
|
|
||||||
await expect(page.getByLabel('View next asset')).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Verify no previous button on first asset', async ({ page }) => {
|
|
||||||
const firstAsset = assets[0];
|
|
||||||
await page.goto(`/photos/${firstAsset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
|
|
||||||
|
|
||||||
// Verify previous button doesn't exist
|
|
||||||
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Delete photo advances to next', async ({ page }) => {
|
|
||||||
const asset = selectRandom(assets, rng);
|
|
||||||
await page.goto(`/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
await page.getByLabel('Delete').click();
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
|
||||||
});
|
|
||||||
test('Delete photo advances to next (2x)', async ({ page }) => {
|
|
||||||
const asset = selectRandom(assets, rng);
|
|
||||||
await page.goto(`/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
await page.getByLabel('Delete').click();
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
|
||||||
await page.getByLabel('Delete').click();
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
|
||||||
});
|
|
||||||
test('Delete last photo advances to prev', async ({ page }) => {
|
|
||||||
const asset = assets.at(-1)!;
|
|
||||||
await page.goto(`/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
await page.getByLabel('Delete').click();
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
|
||||||
});
|
|
||||||
test('Delete last photo advances to prev (2x)', async ({ page }) => {
|
|
||||||
const asset = assets.at(-1)!;
|
|
||||||
await page.goto(`/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
await page.getByLabel('Delete').click();
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
|
||||||
await page.getByLabel('Delete').click();
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
test.describe('/trash/photos/:id', () => {
|
|
||||||
test('Delete trashed photo advances to next', async ({ page }) => {
|
|
||||||
const asset = selectRandom(assets, rng);
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
|
||||||
changes.assetDeletions.push(...deletedAssets);
|
|
||||||
await page.goto(`/trash/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
await page.getByLabel('Delete').click();
|
|
||||||
// confirm dialog
|
|
||||||
await page.getByRole('button').getByText('Delete').click();
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
|
||||||
});
|
|
||||||
test('Delete trashed photo advances to next 2x', async ({ page }) => {
|
|
||||||
const asset = selectRandom(assets, rng);
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
|
||||||
changes.assetDeletions.push(...deletedAssets);
|
|
||||||
await page.goto(`/trash/photos/${asset.id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
|
||||||
await page.getByLabel('Delete').click();
|
|
||||||
// confirm dialog
|
|
||||||
await page.getByRole('button').getByText('Delete').click();
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
|
||||||
await page.getByLabel('Delete').click();
|
|
||||||
// confirm dialog
|
|
||||||
await page.getByRole('button').getByText('Delete').click();
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
|
||||||
});
|
|
||||||
test('Delete trashed photo advances to prev', async ({ page }) => {
|
|
||||||
const asset = selectRandom(assets, rng);
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
|
||||||
changes.assetDeletions.push(...deletedAssets);
|
|
||||||
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
|
||||||
await page.getByLabel('Delete').click();
|
|
||||||
// confirm dialog
|
|
||||||
await page.getByRole('button').getByText('Delete').click();
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
|
||||||
});
|
|
||||||
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
|
|
||||||
const asset = selectRandom(assets, rng);
|
|
||||||
const index = assets.indexOf(asset);
|
|
||||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
|
||||||
changes.assetDeletions.push(...deletedAssets);
|
|
||||||
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
|
||||||
await page.getByLabel('Delete').click();
|
|
||||||
// confirm dialog
|
|
||||||
await page.getByRole('button').getByText('Delete').click();
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
|
||||||
await page.getByLabel('Delete').click();
|
|
||||||
// confirm dialog
|
|
||||||
await page.getByRole('button').getByText('Delete').click();
|
|
||||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+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()],
|
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-9
@@ -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",
|
||||||
@@ -1007,8 +1009,6 @@
|
|||||||
"editor_edits_applied_success": "Edits applied successfully",
|
"editor_edits_applied_success": "Edits applied successfully",
|
||||||
"editor_flip_horizontal": "Flip horizontal",
|
"editor_flip_horizontal": "Flip horizontal",
|
||||||
"editor_flip_vertical": "Flip vertical",
|
"editor_flip_vertical": "Flip vertical",
|
||||||
"editor_handle_corner": "{corner, select, top_left {Top-left} top_right {Top-right} bottom_left {Bottom-left} bottom_right {Bottom-right} other {A}} corner handle",
|
|
||||||
"editor_handle_edge": "{edge, select, top {Top} bottom {Bottom} left {Left} right {Right} other {An}} edge handle",
|
|
||||||
"editor_orientation": "Orientation",
|
"editor_orientation": "Orientation",
|
||||||
"editor_reset_all_changes": "Reset changes",
|
"editor_reset_all_changes": "Reset changes",
|
||||||
"editor_rotate_left": "Rotate 90° counterclockwise",
|
"editor_rotate_left": "Rotate 90° counterclockwise",
|
||||||
@@ -1074,7 +1074,7 @@
|
|||||||
"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",
|
"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",
|
||||||
@@ -1651,7 +1651,6 @@
|
|||||||
"only_favorites": "Only favorites",
|
"only_favorites": "Only favorites",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
"open_calendar": "Open calendar",
|
"open_calendar": "Open calendar",
|
||||||
"open_in_browser": "Open in browser",
|
|
||||||
"open_in_map_view": "Open in map view",
|
"open_in_map_view": "Open in map view",
|
||||||
"open_in_openstreetmap": "Open in OpenStreetMap",
|
"open_in_openstreetmap": "Open in OpenStreetMap",
|
||||||
"open_the_search_filters": "Open the search filters",
|
"open_the_search_filters": "Open the search filters",
|
||||||
@@ -2339,8 +2338,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",
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ class OrtSession:
|
|||||||
def _providers_default(self) -> list[str]:
|
def _providers_default(self) -> list[str]:
|
||||||
available_providers = set(ort.get_available_providers())
|
available_providers = set(ort.get_available_providers())
|
||||||
log.debug(f"Available ORT providers: {available_providers}")
|
log.debug(f"Available ORT providers: {available_providers}")
|
||||||
|
if (openvino := "OpenVINOExecutionProvider") in available_providers:
|
||||||
|
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
||||||
|
log.debug(f"Available OpenVINO devices: {device_ids}")
|
||||||
|
|
||||||
|
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
|
||||||
|
if not gpu_devices:
|
||||||
|
log.warning("No GPU device found in OpenVINO. Falling back to CPU.")
|
||||||
|
available_providers.remove(openvino)
|
||||||
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
|
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -94,19 +102,12 @@ class OrtSession:
|
|||||||
"migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0",
|
"migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0",
|
||||||
}
|
}
|
||||||
case "OpenVINOExecutionProvider":
|
case "OpenVINOExecutionProvider":
|
||||||
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
openvino_dir = self.model_path.parent / "openvino"
|
||||||
# Check for available devices, preferring GPU over CPU
|
device = f"GPU.{settings.device_id}"
|
||||||
gpu_devices = [d for d in device_ids if d.startswith("GPU")]
|
|
||||||
if gpu_devices:
|
|
||||||
device_type = f"GPU.{settings.device_id}"
|
|
||||||
log.debug(f"OpenVINO: Using GPU device {device_type}")
|
|
||||||
else:
|
|
||||||
device_type = "CPU"
|
|
||||||
log.debug("OpenVINO: No GPU found, using CPU")
|
|
||||||
options = {
|
options = {
|
||||||
"device_type": device_type,
|
"device_type": device,
|
||||||
"precision": settings.openvino_precision.value,
|
"precision": settings.openvino_precision.value,
|
||||||
"cache_dir": (self.model_path.parent / "openvino").as_posix(),
|
"cache_dir": openvino_dir.as_posix(),
|
||||||
}
|
}
|
||||||
case "CoreMLExecutionProvider":
|
case "CoreMLExecutionProvider":
|
||||||
options = {
|
options = {
|
||||||
@@ -138,14 +139,12 @@ class OrtSession:
|
|||||||
sess_options.enable_cpu_mem_arena = settings.model_arena
|
sess_options.enable_cpu_mem_arena = settings.model_arena
|
||||||
|
|
||||||
# avoid thread contention between models
|
# avoid thread contention between models
|
||||||
# Set inter_op threads
|
|
||||||
if settings.model_inter_op_threads > 0:
|
if settings.model_inter_op_threads > 0:
|
||||||
sess_options.inter_op_num_threads = settings.model_inter_op_threads
|
sess_options.inter_op_num_threads = settings.model_inter_op_threads
|
||||||
# these defaults work well for CPU, but bottleneck GPU
|
# these defaults work well for CPU, but bottleneck GPU
|
||||||
elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
||||||
sess_options.inter_op_num_threads = 1
|
sess_options.inter_op_num_threads = 1
|
||||||
|
|
||||||
# Set intra_op threads
|
|
||||||
if settings.model_intra_op_threads > 0:
|
if settings.model_intra_op_threads > 0:
|
||||||
sess_options.intra_op_num_threads = settings.model_intra_op_threads
|
sess_options.intra_op_num_threads = settings.model_intra_op_threads
|
||||||
elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
||||||
|
|||||||
+10
-110
@@ -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
|
||||||
@@ -204,6 +201,13 @@ class TestOrtSession:
|
|||||||
|
|
||||||
assert session.providers == self.OV_EP
|
assert session.providers == self.OV_EP
|
||||||
|
|
||||||
|
@pytest.mark.ov_device_ids(["CPU"])
|
||||||
|
@pytest.mark.providers(OV_EP)
|
||||||
|
def test_avoids_openvino_if_gpu_not_available(self, providers: list[str], ov_device_ids: list[str]) -> None:
|
||||||
|
session = OrtSession("ViT-B-32__openai")
|
||||||
|
|
||||||
|
assert session.providers == self.CPU_EP
|
||||||
|
|
||||||
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
|
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
|
||||||
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
|
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
|
||||||
session = OrtSession("ViT-B-32__openai")
|
session = OrtSession("ViT-B-32__openai")
|
||||||
@@ -249,8 +253,7 @@ class TestOrtSession:
|
|||||||
{"arena_extend_strategy": "kSameAsRequested"},
|
{"arena_extend_strategy": "kSameAsRequested"},
|
||||||
]
|
]
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
|
def test_sets_provider_options_for_openvino(self) -> None:
|
||||||
def test_sets_provider_options_for_openvino(self, ov_device_ids: list[str]) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
||||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||||
|
|
||||||
@@ -264,8 +267,7 @@ class TestOrtSession:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
|
def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None:
|
||||||
def test_sets_openvino_to_fp16_if_enabled(self, ov_device_ids: list[str], mocker: MockerFixture) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
||||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||||
mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16)
|
mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16)
|
||||||
@@ -280,19 +282,6 @@ class TestOrtSession:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["CPU"])
|
|
||||||
def test_sets_provider_options_for_openvino_cpu(self, ov_device_ids: list[str]) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
|
||||||
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
|
||||||
|
|
||||||
assert session.provider_options == [
|
|
||||||
{
|
|
||||||
"device_type": "CPU",
|
|
||||||
"precision": "FP32",
|
|
||||||
"cache_dir": "/cache/ViT-B-32__openai/openvino",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_sets_provider_options_for_cuda(self) -> None:
|
def test_sets_provider_options_for_cuda(self) -> None:
|
||||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||||
|
|
||||||
@@ -349,23 +338,6 @@ class TestOrtSession:
|
|||||||
assert session.sess_options.inter_op_num_threads == 1
|
assert session.sess_options.inter_op_num_threads == 1
|
||||||
assert session.sess_options.intra_op_num_threads == 2
|
assert session.sess_options.intra_op_num_threads == 2
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["CPU"])
|
|
||||||
def test_sets_default_sess_options_if_openvino_cpu(self, ov_device_ids: list[str]) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
|
||||||
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
|
||||||
|
|
||||||
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
|
|
||||||
assert session.sess_options.inter_op_num_threads == 0
|
|
||||||
assert session.sess_options.intra_op_num_threads == 0
|
|
||||||
|
|
||||||
@pytest.mark.ov_device_ids(["GPU.0", "CPU"])
|
|
||||||
def test_sets_default_sess_options_if_openvino_gpu(self, ov_device_ids: list[str]) -> None:
|
|
||||||
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
|
||||||
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
|
||||||
|
|
||||||
assert session.sess_options.inter_op_num_threads == 0
|
|
||||||
assert session.sess_options.intra_op_num_threads == 0
|
|
||||||
|
|
||||||
def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None:
|
def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None:
|
||||||
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
|
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
|
||||||
|
|
||||||
@@ -883,78 +855,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:
|
||||||
|
|||||||
@@ -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.30.0"
|
||||||
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"]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
formatter:
|
formatter:
|
||||||
page_width: 120
|
page_width: 120
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ plugins {
|
|||||||
id "kotlin-android"
|
id "kotlin-android"
|
||||||
id "dev.flutter.flutter-gradle-plugin"
|
id "dev.flutter.flutter-gradle-plugin"
|
||||||
id 'com.google.devtools.ksp'
|
id 'com.google.devtools.ksp'
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
|
||||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version
|
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -82,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'
|
||||||
@@ -113,8 +111,6 @@ dependencies {
|
|||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||||
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
|
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
|
||||||
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
|
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
|
||||||
implementation("androidx.media3:media3-datasource-okhttp:1.9.2")
|
|
||||||
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||||
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
||||||
|
|||||||
+1
-9
@@ -36,12 +36,4 @@
|
|||||||
##---------------End: proguard configuration for Gson ----------
|
##---------------End: proguard configuration for Gson ----------
|
||||||
|
|
||||||
# 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
|
|||||||
import app.alextran.immich.core.HttpClientManager
|
import app.alextran.immich.core.HttpClientManager
|
||||||
import app.alextran.immich.core.ImmichPlugin
|
import app.alextran.immich.core.ImmichPlugin
|
||||||
import app.alextran.immich.core.NetworkApiPlugin
|
import app.alextran.immich.core.NetworkApiPlugin
|
||||||
import me.albemala.native_video_player.NativeVideoPlayerPlugin
|
|
||||||
import app.alextran.immich.images.LocalImageApi
|
import app.alextran.immich.images.LocalImageApi
|
||||||
import app.alextran.immich.images.LocalImagesImpl
|
import app.alextran.immich.images.LocalImagesImpl
|
||||||
import app.alextran.immich.images.RemoteImageApi
|
import app.alextran.immich.images.RemoteImageApi
|
||||||
@@ -32,7 +31,6 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
companion object {
|
companion object {
|
||||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||||
HttpClientManager.initialize(ctx)
|
HttpClientManager.initialize(ctx)
|
||||||
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
|
|
||||||
flutterEngine.plugins.add(NetworkApiPlugin())
|
flutterEngine.plugins.add(NetworkApiPlugin())
|
||||||
|
|
||||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||||
|
|||||||
@@ -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,43 +1,18 @@
|
|||||||
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.annotation.OptIn
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.datasource.DataSource
|
|
||||||
import androidx.media3.datasource.ResolvingDataSource
|
|
||||||
import androidx.media3.datasource.cronet.CronetDataSource
|
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
|
||||||
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.Cookie
|
|
||||||
import okhttp3.CookieJar
|
|
||||||
import okhttp3.Credentials
|
|
||||||
import okhttp3.Dispatcher
|
import okhttp3.Dispatcher
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.chromium.net.CronetEngine
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.Authenticator
|
|
||||||
import java.net.CookieHandler
|
|
||||||
import java.net.PasswordAuthentication
|
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.net.URI
|
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import java.security.Principal
|
import java.security.Principal
|
||||||
import java.security.PrivateKey
|
import java.security.PrivateKey
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
import java.util.concurrent.ExecutorService
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.net.ssl.HttpsURLConnection
|
import javax.net.ssl.HttpsURLConnection
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
@@ -45,31 +20,14 @@ 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_URLS = "immich.server_urls"
|
|
||||||
private const val PREFS_COOKIES = "immich.cookies"
|
|
||||||
private const val COOKIE_EXPIRY_DAYS = 400L
|
|
||||||
|
|
||||||
private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
|
|
||||||
ACCESS_TOKEN("immich_access_token", httpOnly = true),
|
|
||||||
IS_AUTHENTICATED("immich_is_authenticated", httpOnly = false),
|
|
||||||
AUTH_TYPE("immich_auth_type", httpOnly = true);
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val names = entries.map { it.cookieName }.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages a shared OkHttpClient with SSL configuration support.
|
* Manages a shared OkHttpClient with SSL configuration support.
|
||||||
*/
|
*/
|
||||||
object HttpClientManager {
|
object HttpClientManager {
|
||||||
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
|
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
|
||||||
const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB
|
|
||||||
private const val KEEP_ALIVE_CONNECTIONS = 10
|
private const val KEEP_ALIVE_CONNECTIONS = 10
|
||||||
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
|
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
|
||||||
private const val MAX_REQUESTS_PER_HOST = 64
|
private const val MAX_REQUESTS_PER_HOST = 64
|
||||||
@@ -78,93 +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
|
|
||||||
|
|
||||||
var cronetEngine: CronetEngine? = null
|
|
||||||
private set
|
|
||||||
private lateinit var cronetStorageDir: File
|
|
||||||
val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
private val cookieJar = PersistentCookieJar()
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
cookieJar.init(prefs)
|
|
||||||
System.setProperty("http.agent", USER_AGENT)
|
|
||||||
Authenticator.setDefault(object : Authenticator() {
|
|
||||||
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
|
||||||
val url = requestingURL ?: return null
|
|
||||||
if (url.userInfo.isNullOrEmpty()) return null
|
|
||||||
val parts = url.userInfo.split(":", limit = 2)
|
|
||||||
return PasswordAuthentication(parts[0], parts.getOrElse(1) { "" }.toCharArray())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
CookieHandler.setDefault(object : CookieHandler() {
|
|
||||||
override fun get(uri: URI, requestHeaders: Map<String, List<String>>): Map<String, List<String>> {
|
|
||||||
val httpUrl = uri.toString().toHttpUrlOrNull() ?: return emptyMap()
|
|
||||||
val cookies = cookieJar.loadForRequest(httpUrl)
|
|
||||||
if (cookies.isEmpty()) return emptyMap()
|
|
||||||
return mapOf("Cookie" to listOf(cookies.joinToString("; ") { "${it.name}=${it.value}" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun put(uri: URI, responseHeaders: Map<String, List<String>>) {}
|
|
||||||
})
|
|
||||||
|
|
||||||
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
|
|
||||||
if (savedHeaders != null) {
|
|
||||||
val map = Json.decodeFromString<Map<String, String>>(savedHeaders)
|
|
||||||
val builder = Headers.Builder()
|
|
||||||
for ((key, value) in map) {
|
|
||||||
builder.add(key, value)
|
|
||||||
}
|
|
||||||
headers = builder.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
val serverUrlsJson = prefs.getString(PREFS_SERVER_URLS, null)
|
|
||||||
if (serverUrlsJson != null) {
|
|
||||||
cookieJar.setServerUrls(Json.decodeFromString<List<String>>(serverUrlsJson))
|
|
||||||
}
|
|
||||||
|
|
||||||
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
||||||
client = build(cacheDir)
|
client = build(cacheDir)
|
||||||
|
|
||||||
cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
|
|
||||||
cronetEngine = buildCronetEngine()
|
|
||||||
|
|
||||||
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
|
||||||
@@ -176,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)
|
||||||
@@ -188,130 +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>, token: String?) {
|
|
||||||
synchronized(this) {
|
|
||||||
val builder = Headers.Builder()
|
|
||||||
headerMap.forEach { (key, value) -> builder[key] = value }
|
|
||||||
val newHeaders = builder.build()
|
|
||||||
|
|
||||||
val headersChanged = headers != newHeaders
|
|
||||||
val urlsChanged = Json.encodeToString(serverUrls) != prefs.getString(PREFS_SERVER_URLS, null)
|
|
||||||
|
|
||||||
headers = newHeaders
|
|
||||||
cookieJar.setServerUrls(serverUrls)
|
|
||||||
|
|
||||||
if (headersChanged || urlsChanged) {
|
|
||||||
prefs.edit {
|
|
||||||
putString(PREFS_HEADERS, Json.encodeToString(headerMap))
|
|
||||||
putString(PREFS_SERVER_URLS, Json.encodeToString(serverUrls))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token != null) {
|
|
||||||
val url = serverUrls.firstNotNullOfOrNull { it.toHttpUrlOrNull() } ?: return
|
|
||||||
val expiry = System.currentTimeMillis() + COOKIE_EXPIRY_DAYS * 24 * 60 * 60 * 1000
|
|
||||||
val values = mapOf(
|
|
||||||
AuthCookie.ACCESS_TOKEN to token,
|
|
||||||
AuthCookie.IS_AUTHENTICATED to "true",
|
|
||||||
AuthCookie.AUTH_TYPE to "password",
|
|
||||||
)
|
|
||||||
cookieJar.saveFromResponse(url, values.map { (cookie, value) ->
|
|
||||||
Cookie.Builder().name(cookie.cookieName).value(value).domain(url.host).path("/").expiresAt(expiry)
|
|
||||||
.apply {
|
|
||||||
if (url.isHttps) secure()
|
|
||||||
if (cookie.httpOnly) httpOnly()
|
|
||||||
}.build()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadCookieHeader(url: String): String? {
|
|
||||||
val httpUrl = url.toHttpUrlOrNull() ?: return null
|
|
||||||
return cookieJar.loadForRequest(httpUrl).takeIf { it.isNotEmpty() }
|
|
||||||
?.joinToString("; ") { "${it.name}=${it.value}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAuthHeaders(url: String): Map<String, String> {
|
|
||||||
val result = mutableMapOf<String, String>()
|
|
||||||
headers.forEach { (key, value) -> result[key] = value }
|
|
||||||
loadCookieHeader(url)?.let { result["Cookie"] = it }
|
|
||||||
url.toHttpUrlOrNull()?.let { httpUrl ->
|
|
||||||
if (httpUrl.username.isNotEmpty()) {
|
|
||||||
result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun rebuildCronetEngine(): CronetEngine {
|
|
||||||
val old = cronetEngine!!
|
|
||||||
cronetEngine = buildCronetEngine()
|
|
||||||
return old
|
|
||||||
}
|
|
||||||
|
|
||||||
val cronetStoragePath: File get() = cronetStorageDir
|
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
|
||||||
fun createDataSourceFactory(headers: Map<String, String>): DataSource.Factory {
|
|
||||||
return if (isMtls) {
|
|
||||||
OkHttpDataSource.Factory(client.newBuilder().cache(null).build())
|
|
||||||
} else {
|
|
||||||
ResolvingDataSource.Factory(
|
|
||||||
CronetDataSource.Factory(cronetEngine!!, cronetExecutor)
|
|
||||||
) { dataSpec ->
|
|
||||||
val newHeaders = dataSpec.httpRequestHeaders.toMutableMap()
|
|
||||||
newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString()))
|
|
||||||
newHeaders["Cache-Control"] = "no-store"
|
|
||||||
dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildCronetEngine(): CronetEngine {
|
|
||||||
return CronetEngine.Builder(appContext)
|
|
||||||
.enableHttp2(true)
|
|
||||||
.enableQuic(true)
|
|
||||||
.enableBrotli(true)
|
|
||||||
.setStoragePath(cronetStorageDir.absolutePath)
|
|
||||||
.setUserAgent(USER_AGENT)
|
|
||||||
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -328,17 +109,8 @@ object HttpClientManager {
|
|||||||
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||||
|
|
||||||
return OkHttpClient.Builder()
|
return OkHttpClient.Builder()
|
||||||
.cookieJar(cookieJar)
|
.addInterceptor { chain ->
|
||||||
.addInterceptor {
|
chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build())
|
||||||
val request = it.request()
|
|
||||||
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 })
|
||||||
@@ -347,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
|
||||||
@@ -390,131 +146,4 @@ object HttpClientManager {
|
|||||||
socket: Socket?
|
socket: Socket?
|
||||||
): String? = null
|
): String? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Persistent CookieJar that duplicates auth cookies across equivalent server URLs.
|
|
||||||
* When the server sets cookies for one domain, copies are created for all other known
|
|
||||||
* server domains (for URL switching between local/remote endpoints of the same server).
|
|
||||||
*/
|
|
||||||
private class PersistentCookieJar : CookieJar {
|
|
||||||
private val store = mutableListOf<Cookie>()
|
|
||||||
private var serverUrls = listOf<HttpUrl>()
|
|
||||||
private var prefs: SharedPreferences? = null
|
|
||||||
|
|
||||||
|
|
||||||
fun init(prefs: SharedPreferences) {
|
|
||||||
this.prefs = prefs
|
|
||||||
restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun setServerUrls(urls: List<String>) {
|
|
||||||
val parsed = urls.mapNotNull { it.toHttpUrlOrNull() }
|
|
||||||
if (parsed.map { it.host } == serverUrls.map { it.host }) return
|
|
||||||
serverUrls = parsed
|
|
||||||
if (syncAuthCookies()) persist()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
|
||||||
val changed = cookies.any { new ->
|
|
||||||
store.none { it.name == new.name && it.domain == new.domain && it.path == new.path && it.value == new.value }
|
|
||||||
}
|
|
||||||
store.removeAll { existing ->
|
|
||||||
cookies.any { it.name == existing.name && it.domain == existing.domain && it.path == existing.path }
|
|
||||||
}
|
|
||||||
store.addAll(cookies)
|
|
||||||
val synced = serverUrls.any { it.host == url.host } && syncAuthCookies()
|
|
||||||
if (changed || synced) persist()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (store.removeAll { it.expiresAt < now }) {
|
|
||||||
syncAuthCookies()
|
|
||||||
persist()
|
|
||||||
}
|
|
||||||
return store.filter { it.matches(url) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun syncAuthCookies(): Boolean {
|
|
||||||
val serverHosts = serverUrls.map { it.host }.toSet()
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val sourceCookies = store
|
|
||||||
.filter { it.name in AuthCookie.names && it.domain in serverHosts && it.expiresAt > now }
|
|
||||||
.associateBy { it.name }
|
|
||||||
|
|
||||||
if (sourceCookies.isEmpty()) {
|
|
||||||
return store.removeAll { it.name in AuthCookie.names && it.domain in serverHosts }
|
|
||||||
}
|
|
||||||
|
|
||||||
var changed = false
|
|
||||||
for (url in serverUrls) {
|
|
||||||
for ((_, source) in sourceCookies) {
|
|
||||||
if (store.any { it.name == source.name && it.domain == url.host && it.value == source.value }) continue
|
|
||||||
store.removeAll { it.name == source.name && it.domain == url.host }
|
|
||||||
store.add(rebuildCookie(source, url))
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun rebuildCookie(source: Cookie, url: HttpUrl): Cookie {
|
|
||||||
return Cookie.Builder()
|
|
||||||
.name(source.name).value(source.value)
|
|
||||||
.domain(url.host).path("/")
|
|
||||||
.expiresAt(source.expiresAt)
|
|
||||||
.apply {
|
|
||||||
if (url.isHttps) secure()
|
|
||||||
if (source.httpOnly) httpOnly()
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun persist() {
|
|
||||||
val p = prefs ?: return
|
|
||||||
p.edit { putString(PREFS_COOKIES, Json.encodeToString(store.map { SerializedCookie.from(it) })) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restore() {
|
|
||||||
val p = prefs ?: return
|
|
||||||
val jsonStr = p.getString(PREFS_COOKIES, null) ?: return
|
|
||||||
try {
|
|
||||||
store.addAll(Json.decodeFromString<List<SerializedCookie>>(jsonStr).map { it.toCookie() })
|
|
||||||
} catch (_: Exception) {
|
|
||||||
store.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class SerializedCookie(
|
|
||||||
val name: String,
|
|
||||||
val value: String,
|
|
||||||
val domain: String,
|
|
||||||
val path: String,
|
|
||||||
val expiresAt: Long,
|
|
||||||
val secure: Boolean,
|
|
||||||
val httpOnly: Boolean,
|
|
||||||
val hostOnly: Boolean,
|
|
||||||
) {
|
|
||||||
fun toCookie(): Cookie = Cookie.Builder()
|
|
||||||
.name(name).value(value).path(path).expiresAt(expiresAt)
|
|
||||||
.apply {
|
|
||||||
if (hostOnly) hostOnlyDomain(domain) else domain(domain)
|
|
||||||
if (secure) secure()
|
|
||||||
if (httpOnly) httpOnly()
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun from(cookie: Cookie) = SerializedCookie(
|
|
||||||
name = cookie.name, value = cookie.value, domain = cookie.domain,
|
|
||||||
path = cookie.path, expiresAt = cookie.expiresAt, secure = cookie.secure,
|
|
||||||
httpOnly = cookie.httpOnly, hostOnly = cookie.hostOnly,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>, token: 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,56 +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 tokenArg = args[2] as String?
|
|
||||||
val wrapped: List<Any?> = try {
|
|
||||||
api.setRequestHeaders(headersArg, serverUrlsArg, tokenArg)
|
|
||||||
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>, token: String?) {
|
val textInputLayout = TextInputLayout(themedContext).apply {
|
||||||
HttpClientManager.setRequestHeaders(headers, serverUrls, token)
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, preferEncoded: Boolean, 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,10 @@ 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>?> ->
|
val preferEncodedArg = args[3] as Boolean
|
||||||
|
api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { 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))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE
|
|||||||
import app.alextran.immich.NativeBuffer
|
import app.alextran.immich.NativeBuffer
|
||||||
import app.alextran.immich.NativeByteBuffer
|
import app.alextran.immich.NativeByteBuffer
|
||||||
import app.alextran.immich.core.HttpClientManager
|
import app.alextran.immich.core.HttpClientManager
|
||||||
|
import app.alextran.immich.core.USER_AGENT
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
@@ -14,6 +15,7 @@ import okhttp3.Callback
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
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
|
||||||
import org.chromium.net.UrlResponseInfo
|
import org.chromium.net.UrlResponseInfo
|
||||||
@@ -27,6 +29,10 @@ import java.nio.file.Path
|
|||||||
import java.nio.file.SimpleFileVisitor
|
import java.nio.file.SimpleFileVisitor
|
||||||
import java.nio.file.attribute.BasicFileAttributes
|
import java.nio.file.attribute.BasicFileAttributes
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
|
||||||
|
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
|
||||||
|
|
||||||
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
||||||
|
|
||||||
@@ -43,6 +49,7 @@ 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
|
@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
|
||||||
@@ -52,6 +59,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)
|
||||||
@@ -93,6 +101,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private object ImageFetcherManager {
|
private object ImageFetcherManager {
|
||||||
|
private lateinit var appContext: Context
|
||||||
private lateinit var cacheDir: File
|
private lateinit var cacheDir: File
|
||||||
private lateinit var fetcher: ImageFetcher
|
private lateinit var fetcher: ImageFetcher
|
||||||
private var initialized = false
|
private var initialized = false
|
||||||
@@ -101,6 +110,7 @@ private object ImageFetcherManager {
|
|||||||
if (initialized) return
|
if (initialized) return
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (initialized) return
|
if (initialized) return
|
||||||
|
appContext = context.applicationContext
|
||||||
cacheDir = context.cacheDir
|
cacheDir = context.cacheDir
|
||||||
fetcher = build()
|
fetcher = build()
|
||||||
HttpClientManager.addClientChangedListener(::invalidate)
|
HttpClientManager.addClientChangedListener(::invalidate)
|
||||||
@@ -110,11 +120,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) {
|
||||||
@@ -133,7 +144,7 @@ private object ImageFetcherManager {
|
|||||||
return if (HttpClientManager.isMtls) {
|
return if (HttpClientManager.isMtls) {
|
||||||
OkHttpImageFetcher.create(cacheDir)
|
OkHttpImageFetcher.create(cacheDir)
|
||||||
} else {
|
} else {
|
||||||
CronetImageFetcher()
|
CronetImageFetcher(appContext, cacheDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +152,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,
|
||||||
@@ -151,14 +163,23 @@ private sealed interface ImageFetcher {
|
|||||||
fun clearCache(onCleared: (Result<Long>) -> Unit)
|
fun clearCache(onCleared: (Result<Long>) -> Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CronetImageFetcher : ImageFetcher {
|
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
|
||||||
|
private val ctx = context
|
||||||
|
private var engine: CronetEngine
|
||||||
|
private val executor = Executors.newFixedThreadPool(4)
|
||||||
private val stateLock = Any()
|
private val stateLock = Any()
|
||||||
private var activeCount = 0
|
private var activeCount = 0
|
||||||
private var draining = false
|
private var draining = false
|
||||||
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
|
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
|
||||||
|
private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
|
||||||
|
|
||||||
|
init {
|
||||||
|
engine = build(context)
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -172,16 +193,24 @@ private class CronetImageFetcher : ImageFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
||||||
val requestBuilder = HttpClientManager.cronetEngine!!
|
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
|
||||||
.newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor)
|
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
||||||
HttpClientManager.getAuthHeaders(url).forEach { (key, value) ->
|
|
||||||
requestBuilder.addHeader(key, value)
|
|
||||||
}
|
|
||||||
val request = requestBuilder.build()
|
val request = requestBuilder.build()
|
||||||
signal.setOnCancelListener(request::cancel)
|
signal.setOnCancelListener(request::cancel)
|
||||||
request.start()
|
request.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun build(ctx: Context): CronetEngine {
|
||||||
|
return CronetEngine.Builder(ctx)
|
||||||
|
.enableHttp2(true)
|
||||||
|
.enableQuic(true)
|
||||||
|
.enableBrotli(true)
|
||||||
|
.setStoragePath(storageDir.absolutePath)
|
||||||
|
.setUserAgent(USER_AGENT)
|
||||||
|
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
private fun onComplete() {
|
private fun onComplete() {
|
||||||
val didDrain = synchronized(stateLock) {
|
val didDrain = synchronized(stateLock) {
|
||||||
activeCount--
|
activeCount--
|
||||||
@@ -204,16 +233,19 @@ private class CronetImageFetcher : ImageFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onDrained() {
|
private fun onDrained() {
|
||||||
|
engine.shutdown()
|
||||||
val onCacheCleared = synchronized(stateLock) {
|
val onCacheCleared = synchronized(stateLock) {
|
||||||
val onCacheCleared = onCacheCleared
|
val onCacheCleared = onCacheCleared
|
||||||
this.onCacheCleared = null
|
this.onCacheCleared = null
|
||||||
onCacheCleared
|
onCacheCleared
|
||||||
}
|
}
|
||||||
if (onCacheCleared != null) {
|
if (onCacheCleared == null) {
|
||||||
val oldEngine = HttpClientManager.rebuildCronetEngine()
|
executor.shutdown()
|
||||||
oldEngine.shutdown()
|
} else {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
|
val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) }
|
||||||
|
// Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result
|
||||||
|
engine = build(ctx)
|
||||||
synchronized(stateLock) { draining = false }
|
synchronized(stateLock) { draining = false }
|
||||||
onCacheCleared(result)
|
onCacheCleared(result)
|
||||||
}
|
}
|
||||||
@@ -340,7 +372,7 @@ private class OkHttpImageFetcher private constructor(
|
|||||||
val dir = File(cacheDir, "okhttp")
|
val dir = File(cacheDir, "okhttp")
|
||||||
|
|
||||||
val client = HttpClientManager.getClient().newBuilder()
|
val client = HttpClientManager.getClient().newBuilder()
|
||||||
.cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES))
|
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return OkHttpImageFetcher(client)
|
return OkHttpImageFetcher(client)
|
||||||
@@ -359,6 +391,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,
|
||||||
@@ -371,6 +404,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)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import android.database.Cursor
|
|||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import android.os.Build
|
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 android.util.Log
|
||||||
@@ -16,7 +15,6 @@ import app.alextran.immich.core.ImmichPlugin
|
|||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.ImageHeaderParser
|
import com.bumptech.glide.load.ImageHeaderParser
|
||||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||||
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -80,25 +78,15 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
add(MediaStore.MediaColumns.IS_FAVORITE)
|
add(MediaStore.MediaColumns.IS_FAVORITE)
|
||||||
}
|
}
|
||||||
if (hasSpecialFormatColumn()) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
add(SPECIAL_FORMAT_COLUMN)
|
add(SPECIAL_FORMAT_COLUMN)
|
||||||
} else {
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
// fallback to mimetype and xmp for playback style detection on older Android versions
|
// Fallback: read XMP from MediaStore to detect Motion Photos
|
||||||
// both only needed if special format column is not available
|
add(MediaStore.MediaColumns.XMP)
|
||||||
add(MediaStore.MediaColumns.MIME_TYPE)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
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(
|
||||||
@@ -135,7 +123,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
|
val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
|
||||||
val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||||
val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
||||||
val mimeTypeColumn = c.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)
|
|
||||||
val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
|
val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
|
||||||
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
||||||
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
||||||
@@ -182,20 +169,19 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
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(
|
val playbackStyle = detectPlaybackStyle(
|
||||||
numericId, rawMediaType, mimeTypeColumn, specialFormatColumn, xmpColumn, c
|
numericId, rawMediaType, specialFormatColumn, xmpColumn, c
|
||||||
)
|
)
|
||||||
|
|
||||||
val isFlipped = orientation == 90 || orientation == 270
|
|
||||||
val asset = PlatformAsset(
|
val asset = PlatformAsset(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
assetType,
|
assetType,
|
||||||
createdAt,
|
createdAt,
|
||||||
modifiedAt,
|
modifiedAt,
|
||||||
if (isFlipped) height else width,
|
width,
|
||||||
if (isFlipped) width else height,
|
height,
|
||||||
duration,
|
duration,
|
||||||
0L,
|
orientation.toLong(),
|
||||||
isFavorite,
|
isFavorite,
|
||||||
playbackStyle = playbackStyle,
|
playbackStyle = playbackStyle,
|
||||||
)
|
)
|
||||||
@@ -206,14 +192,13 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects the playback style for an asset using _special_format (SDK Extension 21+)
|
* Detects the playback style for an asset using _special_format (API 33+)
|
||||||
* or XMP / MIME / RIFF header fallbacks.
|
* or XMP / MIME / RIFF header fallbacks (pre-33).
|
||||||
*/
|
*/
|
||||||
@SuppressLint("NewApi")
|
@SuppressLint("NewApi")
|
||||||
private fun detectPlaybackStyle(
|
private fun detectPlaybackStyle(
|
||||||
assetId: Long,
|
assetId: Long,
|
||||||
rawMediaType: Int,
|
rawMediaType: Int,
|
||||||
mimeTypeColumn: Int,
|
|
||||||
specialFormatColumn: Int,
|
specialFormatColumn: Int,
|
||||||
xmpColumn: Int,
|
xmpColumn: Int,
|
||||||
cursor: Cursor
|
cursor: Cursor
|
||||||
@@ -238,56 +223,46 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
return PlatformAssetPlaybackStyle.UNKNOWN
|
return PlatformAssetPlaybackStyle.UNKNOWN
|
||||||
}
|
}
|
||||||
|
|
||||||
val mimeType = if (mimeTypeColumn != -1) cursor.getString(mimeTypeColumn) else null
|
// Pre-API 33 fallback
|
||||||
|
|
||||||
// GIFs are always animated and cannot be motion photos; no I/O needed
|
|
||||||
if (mimeType == "image/gif") {
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
|
||||||
}
|
|
||||||
|
|
||||||
val uri = ContentUris.withAppendedId(
|
val uri = ContentUris.withAppendedId(
|
||||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||||
assetId
|
assetId
|
||||||
)
|
)
|
||||||
|
|
||||||
// Only WebP needs a stream check to distinguish static vs animated;
|
// Read XMP from cursor (API 30+) or ExifInterface stream (pre-30)
|
||||||
// WebP files are not used as motion photos, so skip XMP detection
|
|
||||||
if (mimeType == "image/webp") {
|
|
||||||
try {
|
|
||||||
val glide = Glide.get(ctx)
|
|
||||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
|
||||||
val type = ImageHeaderParserUtils.getType(
|
|
||||||
listOf(DefaultImageHeaderParser()),
|
|
||||||
stream,
|
|
||||||
glide.arrayPool
|
|
||||||
)
|
|
||||||
// Also check for GIF just in case MIME type is incorrect; Doesn't hurt performance
|
|
||||||
if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP || type == ImageHeaderParser.ImageType.GIF) {
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
|
|
||||||
}
|
|
||||||
// if mimeType is webp but not animated, its just an image.
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Read XMP from cursor (API 30+)
|
|
||||||
val xmp: String? = if (xmpColumn != -1) {
|
val xmp: String? = if (xmpColumn != -1) {
|
||||||
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
|
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
|
||||||
} else {
|
} else {
|
||||||
// if xmp column is not available, we are on API 29 or below
|
try {
|
||||||
// theoretically there were motion photos but the Camera:MotionPhoto xmp tag
|
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
// was only added in Android 11, so we should not have to worry about parsing XMP on older versions
|
ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP)
|
||||||
null
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (xmp != null && "Camera:MotionPhoto" in xmp) {
|
if (xmp != null && "Camera:MotionPhoto" in xmp) {
|
||||||
return PlatformAssetPlaybackStyle.LIVE_PHOTO
|
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
|
return PlatformAssetPlaybackStyle.IMAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-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,6 +1,5 @@
|
|||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
import Flutter
|
import Flutter
|
||||||
import native_video_player
|
|
||||||
import network_info_plus
|
import network_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import permission_handler_apple
|
import permission_handler_apple
|
||||||
@@ -19,8 +18,6 @@ import UIKit
|
|||||||
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||||
}
|
}
|
||||||
|
|
||||||
SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
|
|
||||||
URLSessionManager.patchBackgroundDownloader()
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||||
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
|
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
|
||||||
|
|||||||
@@ -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], token: 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,48 +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]
|
|
||||||
let tokenArg: String? = nilOrValue(args[2])
|
|
||||||
do {
|
|
||||||
try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg, token: tokenArg)
|
|
||||||
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,25 +16,14 @@ 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()
|
||||||
@@ -52,55 +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], token: String?) throws {
|
|
||||||
URLSessionManager.setServerUrls(serverUrls)
|
|
||||||
|
|
||||||
if let token = token {
|
|
||||||
let expiry = Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60)
|
|
||||||
for serverUrl in serverUrls {
|
|
||||||
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
|
|
||||||
let isSecure = serverUrl.hasPrefix("https")
|
|
||||||
let values: [AuthCookie: String] = [
|
|
||||||
.accessToken: token,
|
|
||||||
.isAuthenticated: "true",
|
|
||||||
.authType: "password",
|
|
||||||
]
|
|
||||||
for (cookie, value) in values {
|
|
||||||
var properties: [HTTPCookiePropertyKey: Any] = [
|
|
||||||
.name: cookie.name,
|
|
||||||
.value: value,
|
|
||||||
.domain: domain,
|
|
||||||
.path: "/",
|
|
||||||
.expires: expiry,
|
|
||||||
]
|
|
||||||
if isSecure { properties[.secure] = "TRUE" }
|
|
||||||
if cookie.httpOnly { properties[.init("HttpOnly")] = "TRUE" }
|
|
||||||
if let httpCookie = HTTPCookie(properties: properties) {
|
|
||||||
URLSessionManager.cookieStorage.setCookie(httpCookie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] {
|
|
||||||
UserDefaults.group.set(headers, forKey: HEADERS_KEY)
|
|
||||||
URLSessionManager.shared.recreateSession()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -134,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,241 +1,71 @@
|
|||||||
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_URLS_KEY = "immich.server_urls"
|
|
||||||
let APP_GROUP = "group.app.immich.share"
|
|
||||||
let COOKIE_EXPIRY_DAYS: TimeInterval = 400
|
|
||||||
|
|
||||||
enum AuthCookie: CaseIterable {
|
|
||||||
case accessToken, isAuthenticated, authType
|
|
||||||
|
|
||||||
var name: String {
|
|
||||||
switch self {
|
|
||||||
case .accessToken: return "immich_access_token"
|
|
||||||
case .isAuthenticated: return "immich_is_authenticated"
|
|
||||||
case .authType: return "immich_auth_type"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var httpOnly: Bool {
|
|
||||||
switch self {
|
|
||||||
case .accessToken, .authType: return true
|
|
||||||
case .isAuthenticated: return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let names: Set<String> = Set(allCases.map(\.name))
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}()
|
config.urlCache = URLCache(
|
||||||
private static let urlCache = URLCache(
|
memoryCapacity: 0,
|
||||||
memoryCapacity: 0,
|
diskCapacity: 1024 * 1024 * 1024,
|
||||||
diskCapacity: 1024 * 1024 * 1024,
|
directory: cacheDir
|
||||||
directory: cacheDir
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
private static var serverUrls: [String] = []
|
|
||||||
private static var isSyncing = false
|
|
||||||
|
|
||||||
var sessionPointer: UnsafeMutableRawPointer {
|
|
||||||
Unmanaged.passUnretained(session).toOpaque()
|
|
||||||
}
|
|
||||||
|
|
||||||
private override init() {
|
|
||||||
delegate = URLSessionManagerDelegate()
|
|
||||||
session = Self.buildSession(delegate: delegate)
|
|
||||||
super.init()
|
|
||||||
Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? []
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
Self.self,
|
|
||||||
selector: #selector(Self.cookiesDidChange),
|
|
||||||
name: NSNotification.Name.NSHTTPCookieManagerCookiesChanged,
|
|
||||||
object: Self.cookieStorage
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
func recreateSession() {
|
|
||||||
session = Self.buildSession(delegate: delegate)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setServerUrls(_ urls: [String]) {
|
|
||||||
guard urls != serverUrls else { return }
|
|
||||||
serverUrls = urls
|
|
||||||
UserDefaults.group.set(urls, forKey: SERVER_URLS_KEY)
|
|
||||||
syncAuthCookies()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private static func cookiesDidChange(_ notification: Notification) {
|
|
||||||
guard !isSyncing, !serverUrls.isEmpty else { return }
|
|
||||||
syncAuthCookies()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func syncAuthCookies() {
|
|
||||||
let serverHosts = Set(serverUrls.compactMap { URL(string: $0)?.host })
|
|
||||||
let allCookies = cookieStorage.cookies ?? []
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
let serverAuthCookies = allCookies.filter {
|
|
||||||
AuthCookie.names.contains($0.name) && serverHosts.contains($0.domain)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sourceCookies: [String: HTTPCookie] = [:]
|
|
||||||
for cookie in serverAuthCookies {
|
|
||||||
if cookie.expiresDate.map({ $0 > now }) ?? true {
|
|
||||||
sourceCookies[cookie.name] = cookie
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isSyncing = true
|
|
||||||
defer { isSyncing = false }
|
|
||||||
|
|
||||||
if sourceCookies.isEmpty {
|
|
||||||
for cookie in serverAuthCookies {
|
|
||||||
cookieStorage.deleteCookie(cookie)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for serverUrl in serverUrls {
|
|
||||||
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
|
|
||||||
let isSecure = serverUrl.hasPrefix("https")
|
|
||||||
|
|
||||||
for (_, source) in sourceCookies {
|
|
||||||
if allCookies.contains(where: { $0.name == source.name && $0.domain == domain && $0.value == source.value }) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var properties: [HTTPCookiePropertyKey: Any] = [
|
|
||||||
.name: source.name,
|
|
||||||
.value: source.value,
|
|
||||||
.domain: domain,
|
|
||||||
.path: "/",
|
|
||||||
.expires: source.expiresDate ?? Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60),
|
|
||||||
]
|
|
||||||
if isSecure { properties[.secure] = "TRUE" }
|
|
||||||
if source.isHTTPOnly { properties[.init("HttpOnly")] = "TRUE" }
|
|
||||||
|
|
||||||
if let cookie = HTTPCookie(properties: properties) {
|
|
||||||
cookieStorage.setCookie(cookie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Patches background_downloader's URLSession to use shared auth configuration.
|
|
||||||
/// Must be called before background_downloader creates its session (i.e. early in app startup).
|
|
||||||
static func patchBackgroundDownloader() {
|
|
||||||
// Swizzle URLSessionConfiguration.background(withIdentifier:) to inject shared config
|
|
||||||
let originalSel = NSSelectorFromString("backgroundSessionConfigurationWithIdentifier:")
|
|
||||||
let swizzledSel = #selector(URLSessionConfiguration.immich_background(withIdentifier:))
|
|
||||||
if let original = class_getClassMethod(URLSessionConfiguration.self, originalSel),
|
|
||||||
let swizzled = class_getClassMethod(URLSessionConfiguration.self, swizzledSel) {
|
|
||||||
method_exchangeImplementations(original, swizzled)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add auth challenge handling to background_downloader's UrlSessionDelegate
|
|
||||||
guard let targetClass = NSClassFromString("background_downloader.UrlSessionDelegate") else { return }
|
|
||||||
|
|
||||||
let sessionBlock: @convention(block) (AnyObject, URLSession, URLAuthenticationChallenge,
|
|
||||||
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
|
|
||||||
= { _, session, challenge, completion in
|
|
||||||
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion)
|
|
||||||
}
|
|
||||||
class_replaceMethod(targetClass,
|
|
||||||
NSSelectorFromString("URLSession:didReceiveChallenge:completionHandler:"),
|
|
||||||
imp_implementationWithBlock(sessionBlock), "v@:@@@?")
|
|
||||||
|
|
||||||
let taskBlock: @convention(block) (AnyObject, URLSession, URLSessionTask, URLAuthenticationChallenge,
|
|
||||||
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
|
|
||||||
= { _, session, task, challenge, completion in
|
|
||||||
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion, task: task)
|
|
||||||
}
|
|
||||||
class_replaceMethod(targetClass,
|
|
||||||
NSSelectorFromString("URLSession:task:didReceiveChallenge:completionHandler:"),
|
|
||||||
imp_implementationWithBlock(taskBlock), "v@:@@@@?")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension URLSessionConfiguration {
|
|
||||||
@objc dynamic class func immich_background(withIdentifier id: String) -> URLSessionConfiguration {
|
|
||||||
// After swizzle, this calls the original implementation
|
|
||||||
let config = immich_background(withIdentifier: id)
|
|
||||||
config.httpCookieStorage = URLSessionManager.cookieStorage
|
|
||||||
config.httpAdditionalHeaders = ["User-Agent": URLSessionManager.userAgent]
|
|
||||||
return config
|
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(
|
||||||
_ session: URLSession,
|
_ session: URLSession,
|
||||||
task: URLSessionTask,
|
task: URLSessionTask,
|
||||||
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] = [
|
||||||
@@ -243,36 +73,15 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
|||||||
kSecAttrLabel as String: CLIENT_CERT_LABEL,
|
kSecAttrLabel as String: CLIENT_CERT_LABEL,
|
||||||
kSecReturnRef as String: true,
|
kSecReturnRef as String: true,
|
||||||
]
|
]
|
||||||
|
|
||||||
var item: CFTypeRef?
|
var item: CFTypeRef?
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||||
if status == errSecSuccess, let identity = item {
|
if status == errSecSuccess, let identity = item {
|
||||||
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 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, preferEncoded: Bool, 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,10 @@ 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
|
let preferEncodedArg = args[3] as! Bool
|
||||||
|
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let res):
|
case .success(let res):
|
||||||
reply(wrapResult(res))
|
reply(wrapResult(res))
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ 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, preferEncoded: Bool, 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, encoded: preferEncoded, data: data, response: response, error: error)
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ const String defaultColorPresetName = "indigo";
|
|||||||
|
|
||||||
const Color immichBrandColorLight = Color(0xFF4150AF);
|
const Color immichBrandColorLight = Color(0xFF4150AF);
|
||||||
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
||||||
const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75);
|
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
|
||||||
const Color red400 = Color(0xFFEF5350);
|
const Color red400 = Color(0xFFEF5350);
|
||||||
const Color grey200 = Color(0xFFEEEEEE);
|
const Color grey200 = Color(0xFFEEEEEE);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -46,15 +42,6 @@ sealed class BaseAsset {
|
|||||||
bool get isVideo => type == AssetType.video;
|
bool get isVideo => type == AssetType.video;
|
||||||
|
|
||||||
bool get isMotionPhoto => livePhotoVideoId != null;
|
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||||
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
|
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,10 +205,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:
|
||||||
@@ -340,43 +336,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import 'package:drift/drift.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
|
||||||
|
|
||||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)')
|
|
||||||
class AssetEditEntity extends Table with DriftDefaultsMixin {
|
|
||||||
const AssetEditEntity();
|
|
||||||
|
|
||||||
TextColumn get id => text()();
|
|
||||||
|
|
||||||
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
|
|
||||||
|
|
||||||
IntColumn get action => intEnum<AssetEditAction>()();
|
|
||||||
|
|
||||||
BlobColumn get parameters => blob().map(editParameterConverter)();
|
|
||||||
|
|
||||||
IntColumn get sequence => integer()();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Set<Column> get primaryKey => {id};
|
|
||||||
}
|
|
||||||
|
|
||||||
final JsonTypeConverter2<Map<String, Object?>, Uint8List, Object?> editParameterConverter = TypeConverter.jsonb(
|
|
||||||
fromJson: (json) => json as Map<String, Object?>,
|
|
||||||
);
|
|
||||||
|
|
||||||
extension AssetEditEntityDataDomainEx on AssetEditEntityData {
|
|
||||||
AssetEdit toDto() {
|
|
||||||
return AssetEdit(action: action, parameters: parameters);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,752 +0,0 @@
|
|||||||
// dart format width=80
|
|
||||||
// ignore_for_file: type=lint
|
|
||||||
import 'package:drift/drift.dart' as i0;
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
|
|
||||||
as i1;
|
|
||||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart' as i2;
|
|
||||||
import 'dart:typed_data' as i3;
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'
|
|
||||||
as i4;
|
|
||||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
|
|
||||||
as i5;
|
|
||||||
import 'package:drift/internal/modular.dart' as i6;
|
|
||||||
|
|
||||||
typedef $$AssetEditEntityTableCreateCompanionBuilder =
|
|
||||||
i1.AssetEditEntityCompanion Function({
|
|
||||||
required String id,
|
|
||||||
required String assetId,
|
|
||||||
required i2.AssetEditAction action,
|
|
||||||
required Map<String, Object?> parameters,
|
|
||||||
required int sequence,
|
|
||||||
});
|
|
||||||
typedef $$AssetEditEntityTableUpdateCompanionBuilder =
|
|
||||||
i1.AssetEditEntityCompanion Function({
|
|
||||||
i0.Value<String> id,
|
|
||||||
i0.Value<String> assetId,
|
|
||||||
i0.Value<i2.AssetEditAction> action,
|
|
||||||
i0.Value<Map<String, Object?>> parameters,
|
|
||||||
i0.Value<int> sequence,
|
|
||||||
});
|
|
||||||
|
|
||||||
final class $$AssetEditEntityTableReferences
|
|
||||||
extends
|
|
||||||
i0.BaseReferences<
|
|
||||||
i0.GeneratedDatabase,
|
|
||||||
i1.$AssetEditEntityTable,
|
|
||||||
i1.AssetEditEntityData
|
|
||||||
> {
|
|
||||||
$$AssetEditEntityTableReferences(
|
|
||||||
super.$_db,
|
|
||||||
super.$_table,
|
|
||||||
super.$_typedResult,
|
|
||||||
);
|
|
||||||
|
|
||||||
static i5.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
|
|
||||||
i6.ReadDatabaseContainer(db)
|
|
||||||
.resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity')
|
|
||||||
.createAlias(
|
|
||||||
i0.$_aliasNameGenerator(
|
|
||||||
i6.ReadDatabaseContainer(db)
|
|
||||||
.resultSet<i1.$AssetEditEntityTable>('asset_edit_entity')
|
|
||||||
.assetId,
|
|
||||||
i6.ReadDatabaseContainer(
|
|
||||||
db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity').id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
i5.$$RemoteAssetEntityTableProcessedTableManager get assetId {
|
|
||||||
final $_column = $_itemColumn<String>('asset_id')!;
|
|
||||||
|
|
||||||
final manager = i5
|
|
||||||
.$$RemoteAssetEntityTableTableManager(
|
|
||||||
$_db,
|
|
||||||
i6.ReadDatabaseContainer(
|
|
||||||
$_db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
)
|
|
||||||
.filter((f) => f.id.sqlEquals($_column));
|
|
||||||
final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
|
|
||||||
if (item == null) return manager;
|
|
||||||
return i0.ProcessedTableManager(
|
|
||||||
manager.$state.copyWith(prefetchedData: [item]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class $$AssetEditEntityTableFilterComposer
|
|
||||||
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
|
|
||||||
$$AssetEditEntityTableFilterComposer({
|
|
||||||
required super.$db,
|
|
||||||
required super.$table,
|
|
||||||
super.joinBuilder,
|
|
||||||
super.$addJoinBuilderToRootComposer,
|
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
|
||||||
});
|
|
||||||
i0.ColumnFilters<String> get id => $composableBuilder(
|
|
||||||
column: $table.id,
|
|
||||||
builder: (column) => i0.ColumnFilters(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.ColumnWithTypeConverterFilters<i2.AssetEditAction, i2.AssetEditAction, int>
|
|
||||||
get action => $composableBuilder(
|
|
||||||
column: $table.action,
|
|
||||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.ColumnWithTypeConverterFilters<
|
|
||||||
Map<String, Object?>,
|
|
||||||
Map<String, Object>,
|
|
||||||
i3.Uint8List
|
|
||||||
>
|
|
||||||
get parameters => $composableBuilder(
|
|
||||||
column: $table.parameters,
|
|
||||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.ColumnFilters<int> get sequence => $composableBuilder(
|
|
||||||
column: $table.sequence,
|
|
||||||
builder: (column) => i0.ColumnFilters(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i5.$$RemoteAssetEntityTableFilterComposer get assetId {
|
|
||||||
final i5.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
|
|
||||||
composer: this,
|
|
||||||
getCurrentColumn: (t) => t.assetId,
|
|
||||||
referencedTable: i6.ReadDatabaseContainer(
|
|
||||||
$db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
getReferencedColumn: (t) => t.id,
|
|
||||||
builder:
|
|
||||||
(
|
|
||||||
joinBuilder, {
|
|
||||||
$addJoinBuilderToRootComposer,
|
|
||||||
$removeJoinBuilderFromRootComposer,
|
|
||||||
}) => i5.$$RemoteAssetEntityTableFilterComposer(
|
|
||||||
$db: $db,
|
|
||||||
$table: i6.ReadDatabaseContainer(
|
|
||||||
$db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
|
||||||
joinBuilder: joinBuilder,
|
|
||||||
$removeJoinBuilderFromRootComposer:
|
|
||||||
$removeJoinBuilderFromRootComposer,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return composer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class $$AssetEditEntityTableOrderingComposer
|
|
||||||
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
|
|
||||||
$$AssetEditEntityTableOrderingComposer({
|
|
||||||
required super.$db,
|
|
||||||
required super.$table,
|
|
||||||
super.joinBuilder,
|
|
||||||
super.$addJoinBuilderToRootComposer,
|
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
|
||||||
});
|
|
||||||
i0.ColumnOrderings<String> get id => $composableBuilder(
|
|
||||||
column: $table.id,
|
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.ColumnOrderings<int> get action => $composableBuilder(
|
|
||||||
column: $table.action,
|
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.ColumnOrderings<i3.Uint8List> get parameters => $composableBuilder(
|
|
||||||
column: $table.parameters,
|
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.ColumnOrderings<int> get sequence => $composableBuilder(
|
|
||||||
column: $table.sequence,
|
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
|
||||||
);
|
|
||||||
|
|
||||||
i5.$$RemoteAssetEntityTableOrderingComposer get assetId {
|
|
||||||
final i5.$$RemoteAssetEntityTableOrderingComposer composer =
|
|
||||||
$composerBuilder(
|
|
||||||
composer: this,
|
|
||||||
getCurrentColumn: (t) => t.assetId,
|
|
||||||
referencedTable: i6.ReadDatabaseContainer(
|
|
||||||
$db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
getReferencedColumn: (t) => t.id,
|
|
||||||
builder:
|
|
||||||
(
|
|
||||||
joinBuilder, {
|
|
||||||
$addJoinBuilderToRootComposer,
|
|
||||||
$removeJoinBuilderFromRootComposer,
|
|
||||||
}) => i5.$$RemoteAssetEntityTableOrderingComposer(
|
|
||||||
$db: $db,
|
|
||||||
$table: i6.ReadDatabaseContainer(
|
|
||||||
$db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
|
||||||
joinBuilder: joinBuilder,
|
|
||||||
$removeJoinBuilderFromRootComposer:
|
|
||||||
$removeJoinBuilderFromRootComposer,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return composer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class $$AssetEditEntityTableAnnotationComposer
|
|
||||||
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
|
|
||||||
$$AssetEditEntityTableAnnotationComposer({
|
|
||||||
required super.$db,
|
|
||||||
required super.$table,
|
|
||||||
super.joinBuilder,
|
|
||||||
super.$addJoinBuilderToRootComposer,
|
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
|
||||||
});
|
|
||||||
i0.GeneratedColumn<String> get id =>
|
|
||||||
$composableBuilder(column: $table.id, builder: (column) => column);
|
|
||||||
|
|
||||||
i0.GeneratedColumnWithTypeConverter<i2.AssetEditAction, int> get action =>
|
|
||||||
$composableBuilder(column: $table.action, builder: (column) => column);
|
|
||||||
|
|
||||||
i0.GeneratedColumnWithTypeConverter<Map<String, Object?>, i3.Uint8List>
|
|
||||||
get parameters => $composableBuilder(
|
|
||||||
column: $table.parameters,
|
|
||||||
builder: (column) => column,
|
|
||||||
);
|
|
||||||
|
|
||||||
i0.GeneratedColumn<int> get sequence =>
|
|
||||||
$composableBuilder(column: $table.sequence, builder: (column) => column);
|
|
||||||
|
|
||||||
i5.$$RemoteAssetEntityTableAnnotationComposer get assetId {
|
|
||||||
final i5.$$RemoteAssetEntityTableAnnotationComposer composer =
|
|
||||||
$composerBuilder(
|
|
||||||
composer: this,
|
|
||||||
getCurrentColumn: (t) => t.assetId,
|
|
||||||
referencedTable: i6.ReadDatabaseContainer(
|
|
||||||
$db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
getReferencedColumn: (t) => t.id,
|
|
||||||
builder:
|
|
||||||
(
|
|
||||||
joinBuilder, {
|
|
||||||
$addJoinBuilderToRootComposer,
|
|
||||||
$removeJoinBuilderFromRootComposer,
|
|
||||||
}) => i5.$$RemoteAssetEntityTableAnnotationComposer(
|
|
||||||
$db: $db,
|
|
||||||
$table: i6.ReadDatabaseContainer(
|
|
||||||
$db,
|
|
||||||
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
|
|
||||||
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
|
|
||||||
joinBuilder: joinBuilder,
|
|
||||||
$removeJoinBuilderFromRootComposer:
|
|
||||||
$removeJoinBuilderFromRootComposer,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return composer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class $$AssetEditEntityTableTableManager
|
|
||||||
extends
|
|
||||||
i0.RootTableManager<
|
|
||||||
i0.GeneratedDatabase,
|
|
||||||
i1.$AssetEditEntityTable,
|
|
||||||
i1.AssetEditEntityData,
|
|
||||||
i1.$$AssetEditEntityTableFilterComposer,
|
|
||||||
i1.$$AssetEditEntityTableOrderingComposer,
|
|
||||||
i1.$$AssetEditEntityTableAnnotationComposer,
|
|
||||||
$$AssetEditEntityTableCreateCompanionBuilder,
|
|
||||||
$$AssetEditEntityTableUpdateCompanionBuilder,
|
|
||||||
(i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences),
|
|
||||||
i1.AssetEditEntityData,
|
|
||||||
i0.PrefetchHooks Function({bool assetId})
|
|
||||||
> {
|
|
||||||
$$AssetEditEntityTableTableManager(
|
|
||||||
i0.GeneratedDatabase db,
|
|
||||||
i1.$AssetEditEntityTable table,
|
|
||||||
) : super(
|
|
||||||
i0.TableManagerState(
|
|
||||||
db: db,
|
|
||||||
table: table,
|
|
||||||
createFilteringComposer: () =>
|
|
||||||
i1.$$AssetEditEntityTableFilterComposer($db: db, $table: table),
|
|
||||||
createOrderingComposer: () =>
|
|
||||||
i1.$$AssetEditEntityTableOrderingComposer($db: db, $table: table),
|
|
||||||
createComputedFieldComposer: () => i1
|
|
||||||
.$$AssetEditEntityTableAnnotationComposer($db: db, $table: table),
|
|
||||||
updateCompanionCallback:
|
|
||||||
({
|
|
||||||
i0.Value<String> id = const i0.Value.absent(),
|
|
||||||
i0.Value<String> assetId = const i0.Value.absent(),
|
|
||||||
i0.Value<i2.AssetEditAction> action = const i0.Value.absent(),
|
|
||||||
i0.Value<Map<String, Object?>> parameters =
|
|
||||||
const i0.Value.absent(),
|
|
||||||
i0.Value<int> sequence = const i0.Value.absent(),
|
|
||||||
}) => i1.AssetEditEntityCompanion(
|
|
||||||
id: id,
|
|
||||||
assetId: assetId,
|
|
||||||
action: action,
|
|
||||||
parameters: parameters,
|
|
||||||
sequence: sequence,
|
|
||||||
),
|
|
||||||
createCompanionCallback:
|
|
||||||
({
|
|
||||||
required String id,
|
|
||||||
required String assetId,
|
|
||||||
required i2.AssetEditAction action,
|
|
||||||
required Map<String, Object?> parameters,
|
|
||||||
required int sequence,
|
|
||||||
}) => i1.AssetEditEntityCompanion.insert(
|
|
||||||
id: id,
|
|
||||||
assetId: assetId,
|
|
||||||
action: action,
|
|
||||||
parameters: parameters,
|
|
||||||
sequence: sequence,
|
|
||||||
),
|
|
||||||
withReferenceMapper: (p0) => p0
|
|
||||||
.map(
|
|
||||||
(e) => (
|
|
||||||
e.readTable(table),
|
|
||||||
i1.$$AssetEditEntityTableReferences(db, table, e),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
prefetchHooksCallback: ({assetId = false}) {
|
|
||||||
return i0.PrefetchHooks(
|
|
||||||
db: db,
|
|
||||||
explicitlyWatchedTables: [],
|
|
||||||
addJoins:
|
|
||||||
<
|
|
||||||
T extends i0.TableManagerState<
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic,
|
|
||||||
dynamic
|
|
||||||
>
|
|
||||||
>(state) {
|
|
||||||
if (assetId) {
|
|
||||||
state =
|
|
||||||
state.withJoin(
|
|
||||||
currentTable: table,
|
|
||||||
currentColumn: table.assetId,
|
|
||||||
referencedTable: i1
|
|
||||||
.$$AssetEditEntityTableReferences
|
|
||||||
._assetIdTable(db),
|
|
||||||
referencedColumn: i1
|
|
||||||
.$$AssetEditEntityTableReferences
|
|
||||||
._assetIdTable(db)
|
|
||||||
.id,
|
|
||||||
)
|
|
||||||
as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
return state;
|
|
||||||
},
|
|
||||||
getPrefetchedDataCallback: (items) async {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
typedef $$AssetEditEntityTableProcessedTableManager =
|
|
||||||
i0.ProcessedTableManager<
|
|
||||||
i0.GeneratedDatabase,
|
|
||||||
i1.$AssetEditEntityTable,
|
|
||||||
i1.AssetEditEntityData,
|
|
||||||
i1.$$AssetEditEntityTableFilterComposer,
|
|
||||||
i1.$$AssetEditEntityTableOrderingComposer,
|
|
||||||
i1.$$AssetEditEntityTableAnnotationComposer,
|
|
||||||
$$AssetEditEntityTableCreateCompanionBuilder,
|
|
||||||
$$AssetEditEntityTableUpdateCompanionBuilder,
|
|
||||||
(i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences),
|
|
||||||
i1.AssetEditEntityData,
|
|
||||||
i0.PrefetchHooks Function({bool assetId})
|
|
||||||
>;
|
|
||||||
i0.Index get idxAssetEditAssetId => i0.Index(
|
|
||||||
'idx_asset_edit_asset_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
|
||||||
);
|
|
||||||
|
|
||||||
class $AssetEditEntityTable extends i4.AssetEditEntity
|
|
||||||
with i0.TableInfo<$AssetEditEntityTable, i1.AssetEditEntityData> {
|
|
||||||
@override
|
|
||||||
final i0.GeneratedDatabase attachedDatabase;
|
|
||||||
final String? _alias;
|
|
||||||
$AssetEditEntityTable(this.attachedDatabase, [this._alias]);
|
|
||||||
static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
|
|
||||||
@override
|
|
||||||
late final i0.GeneratedColumn<String> id = i0.GeneratedColumn<String>(
|
|
||||||
'id',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i0.DriftSqlType.string,
|
|
||||||
requiredDuringInsert: true,
|
|
||||||
);
|
|
||||||
static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta(
|
|
||||||
'assetId',
|
|
||||||
);
|
|
||||||
@override
|
|
||||||
late final i0.GeneratedColumn<String> assetId = i0.GeneratedColumn<String>(
|
|
||||||
'asset_id',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i0.DriftSqlType.string,
|
|
||||||
requiredDuringInsert: true,
|
|
||||||
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
|
|
||||||
'REFERENCES remote_asset_entity (id) ON DELETE CASCADE',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@override
|
|
||||||
late final i0.GeneratedColumnWithTypeConverter<i2.AssetEditAction, int>
|
|
||||||
action =
|
|
||||||
i0.GeneratedColumn<int>(
|
|
||||||
'action',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i0.DriftSqlType.int,
|
|
||||||
requiredDuringInsert: true,
|
|
||||||
).withConverter<i2.AssetEditAction>(
|
|
||||||
i1.$AssetEditEntityTable.$converteraction,
|
|
||||||
);
|
|
||||||
@override
|
|
||||||
late final i0.GeneratedColumnWithTypeConverter<
|
|
||||||
Map<String, Object?>,
|
|
||||||
i3.Uint8List
|
|
||||||
>
|
|
||||||
parameters =
|
|
||||||
i0.GeneratedColumn<i3.Uint8List>(
|
|
||||||
'parameters',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i0.DriftSqlType.blob,
|
|
||||||
requiredDuringInsert: true,
|
|
||||||
).withConverter<Map<String, Object?>>(
|
|
||||||
i1.$AssetEditEntityTable.$converterparameters,
|
|
||||||
);
|
|
||||||
static const i0.VerificationMeta _sequenceMeta = const i0.VerificationMeta(
|
|
||||||
'sequence',
|
|
||||||
);
|
|
||||||
@override
|
|
||||||
late final i0.GeneratedColumn<int> sequence = i0.GeneratedColumn<int>(
|
|
||||||
'sequence',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i0.DriftSqlType.int,
|
|
||||||
requiredDuringInsert: true,
|
|
||||||
);
|
|
||||||
@override
|
|
||||||
List<i0.GeneratedColumn> get $columns => [
|
|
||||||
id,
|
|
||||||
assetId,
|
|
||||||
action,
|
|
||||||
parameters,
|
|
||||||
sequence,
|
|
||||||
];
|
|
||||||
@override
|
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
|
||||||
@override
|
|
||||||
String get actualTableName => $name;
|
|
||||||
static const String $name = 'asset_edit_entity';
|
|
||||||
@override
|
|
||||||
i0.VerificationContext validateIntegrity(
|
|
||||||
i0.Insertable<i1.AssetEditEntityData> instance, {
|
|
||||||
bool isInserting = false,
|
|
||||||
}) {
|
|
||||||
final context = i0.VerificationContext();
|
|
||||||
final data = instance.toColumns(true);
|
|
||||||
if (data.containsKey('id')) {
|
|
||||||
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
|
|
||||||
} else if (isInserting) {
|
|
||||||
context.missing(_idMeta);
|
|
||||||
}
|
|
||||||
if (data.containsKey('asset_id')) {
|
|
||||||
context.handle(
|
|
||||||
_assetIdMeta,
|
|
||||||
assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta),
|
|
||||||
);
|
|
||||||
} else if (isInserting) {
|
|
||||||
context.missing(_assetIdMeta);
|
|
||||||
}
|
|
||||||
if (data.containsKey('sequence')) {
|
|
||||||
context.handle(
|
|
||||||
_sequenceMeta,
|
|
||||||
sequence.isAcceptableOrUnknown(data['sequence']!, _sequenceMeta),
|
|
||||||
);
|
|
||||||
} else if (isInserting) {
|
|
||||||
context.missing(_sequenceMeta);
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Set<i0.GeneratedColumn> get $primaryKey => {id};
|
|
||||||
@override
|
|
||||||
i1.AssetEditEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
|
|
||||||
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
|
||||||
return i1.AssetEditEntityData(
|
|
||||||
id: attachedDatabase.typeMapping.read(
|
|
||||||
i0.DriftSqlType.string,
|
|
||||||
data['${effectivePrefix}id'],
|
|
||||||
)!,
|
|
||||||
assetId: attachedDatabase.typeMapping.read(
|
|
||||||
i0.DriftSqlType.string,
|
|
||||||
data['${effectivePrefix}asset_id'],
|
|
||||||
)!,
|
|
||||||
action: i1.$AssetEditEntityTable.$converteraction.fromSql(
|
|
||||||
attachedDatabase.typeMapping.read(
|
|
||||||
i0.DriftSqlType.int,
|
|
||||||
data['${effectivePrefix}action'],
|
|
||||||
)!,
|
|
||||||
),
|
|
||||||
parameters: i1.$AssetEditEntityTable.$converterparameters.fromSql(
|
|
||||||
attachedDatabase.typeMapping.read(
|
|
||||||
i0.DriftSqlType.blob,
|
|
||||||
data['${effectivePrefix}parameters'],
|
|
||||||
)!,
|
|
||||||
),
|
|
||||||
sequence: attachedDatabase.typeMapping.read(
|
|
||||||
i0.DriftSqlType.int,
|
|
||||||
data['${effectivePrefix}sequence'],
|
|
||||||
)!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
$AssetEditEntityTable createAlias(String alias) {
|
|
||||||
return $AssetEditEntityTable(attachedDatabase, alias);
|
|
||||||
}
|
|
||||||
|
|
||||||
static i0.JsonTypeConverter2<i2.AssetEditAction, int, int> $converteraction =
|
|
||||||
const i0.EnumIndexConverter<i2.AssetEditAction>(
|
|
||||||
i2.AssetEditAction.values,
|
|
||||||
);
|
|
||||||
static i0.JsonTypeConverter2<Map<String, Object?>, i3.Uint8List, Object?>
|
|
||||||
$converterparameters = i4.editParameterConverter;
|
|
||||||
@override
|
|
||||||
bool get withoutRowId => true;
|
|
||||||
@override
|
|
||||||
bool get isStrict => true;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AssetEditEntityData extends i0.DataClass
|
|
||||||
implements i0.Insertable<i1.AssetEditEntityData> {
|
|
||||||
final String id;
|
|
||||||
final String assetId;
|
|
||||||
final i2.AssetEditAction action;
|
|
||||||
final Map<String, Object?> parameters;
|
|
||||||
final int sequence;
|
|
||||||
const AssetEditEntityData({
|
|
||||||
required this.id,
|
|
||||||
required this.assetId,
|
|
||||||
required this.action,
|
|
||||||
required this.parameters,
|
|
||||||
required this.sequence,
|
|
||||||
});
|
|
||||||
@override
|
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
|
||||||
final map = <String, i0.Expression>{};
|
|
||||||
map['id'] = i0.Variable<String>(id);
|
|
||||||
map['asset_id'] = i0.Variable<String>(assetId);
|
|
||||||
{
|
|
||||||
map['action'] = i0.Variable<int>(
|
|
||||||
i1.$AssetEditEntityTable.$converteraction.toSql(action),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
map['parameters'] = i0.Variable<i3.Uint8List>(
|
|
||||||
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
map['sequence'] = i0.Variable<int>(sequence);
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
factory AssetEditEntityData.fromJson(
|
|
||||||
Map<String, dynamic> json, {
|
|
||||||
i0.ValueSerializer? serializer,
|
|
||||||
}) {
|
|
||||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
|
||||||
return AssetEditEntityData(
|
|
||||||
id: serializer.fromJson<String>(json['id']),
|
|
||||||
assetId: serializer.fromJson<String>(json['assetId']),
|
|
||||||
action: i1.$AssetEditEntityTable.$converteraction.fromJson(
|
|
||||||
serializer.fromJson<int>(json['action']),
|
|
||||||
),
|
|
||||||
parameters: i1.$AssetEditEntityTable.$converterparameters.fromJson(
|
|
||||||
serializer.fromJson<Object?>(json['parameters']),
|
|
||||||
),
|
|
||||||
sequence: serializer.fromJson<int>(json['sequence']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@override
|
|
||||||
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
|
|
||||||
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
|
|
||||||
return <String, dynamic>{
|
|
||||||
'id': serializer.toJson<String>(id),
|
|
||||||
'assetId': serializer.toJson<String>(assetId),
|
|
||||||
'action': serializer.toJson<int>(
|
|
||||||
i1.$AssetEditEntityTable.$converteraction.toJson(action),
|
|
||||||
),
|
|
||||||
'parameters': serializer.toJson<Object?>(
|
|
||||||
i1.$AssetEditEntityTable.$converterparameters.toJson(parameters),
|
|
||||||
),
|
|
||||||
'sequence': serializer.toJson<int>(sequence),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
i1.AssetEditEntityData copyWith({
|
|
||||||
String? id,
|
|
||||||
String? assetId,
|
|
||||||
i2.AssetEditAction? action,
|
|
||||||
Map<String, Object?>? parameters,
|
|
||||||
int? sequence,
|
|
||||||
}) => i1.AssetEditEntityData(
|
|
||||||
id: id ?? this.id,
|
|
||||||
assetId: assetId ?? this.assetId,
|
|
||||||
action: action ?? this.action,
|
|
||||||
parameters: parameters ?? this.parameters,
|
|
||||||
sequence: sequence ?? this.sequence,
|
|
||||||
);
|
|
||||||
AssetEditEntityData copyWithCompanion(i1.AssetEditEntityCompanion data) {
|
|
||||||
return AssetEditEntityData(
|
|
||||||
id: data.id.present ? data.id.value : this.id,
|
|
||||||
assetId: data.assetId.present ? data.assetId.value : this.assetId,
|
|
||||||
action: data.action.present ? data.action.value : this.action,
|
|
||||||
parameters: data.parameters.present
|
|
||||||
? data.parameters.value
|
|
||||||
: this.parameters,
|
|
||||||
sequence: data.sequence.present ? data.sequence.value : this.sequence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return (StringBuffer('AssetEditEntityData(')
|
|
||||||
..write('id: $id, ')
|
|
||||||
..write('assetId: $assetId, ')
|
|
||||||
..write('action: $action, ')
|
|
||||||
..write('parameters: $parameters, ')
|
|
||||||
..write('sequence: $sequence')
|
|
||||||
..write(')'))
|
|
||||||
.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(id, assetId, action, parameters, sequence);
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) =>
|
|
||||||
identical(this, other) ||
|
|
||||||
(other is i1.AssetEditEntityData &&
|
|
||||||
other.id == this.id &&
|
|
||||||
other.assetId == this.assetId &&
|
|
||||||
other.action == this.action &&
|
|
||||||
other.parameters == this.parameters &&
|
|
||||||
other.sequence == this.sequence);
|
|
||||||
}
|
|
||||||
|
|
||||||
class AssetEditEntityCompanion
|
|
||||||
extends i0.UpdateCompanion<i1.AssetEditEntityData> {
|
|
||||||
final i0.Value<String> id;
|
|
||||||
final i0.Value<String> assetId;
|
|
||||||
final i0.Value<i2.AssetEditAction> action;
|
|
||||||
final i0.Value<Map<String, Object?>> parameters;
|
|
||||||
final i0.Value<int> sequence;
|
|
||||||
const AssetEditEntityCompanion({
|
|
||||||
this.id = const i0.Value.absent(),
|
|
||||||
this.assetId = const i0.Value.absent(),
|
|
||||||
this.action = const i0.Value.absent(),
|
|
||||||
this.parameters = const i0.Value.absent(),
|
|
||||||
this.sequence = const i0.Value.absent(),
|
|
||||||
});
|
|
||||||
AssetEditEntityCompanion.insert({
|
|
||||||
required String id,
|
|
||||||
required String assetId,
|
|
||||||
required i2.AssetEditAction action,
|
|
||||||
required Map<String, Object?> parameters,
|
|
||||||
required int sequence,
|
|
||||||
}) : id = i0.Value(id),
|
|
||||||
assetId = i0.Value(assetId),
|
|
||||||
action = i0.Value(action),
|
|
||||||
parameters = i0.Value(parameters),
|
|
||||||
sequence = i0.Value(sequence);
|
|
||||||
static i0.Insertable<i1.AssetEditEntityData> custom({
|
|
||||||
i0.Expression<String>? id,
|
|
||||||
i0.Expression<String>? assetId,
|
|
||||||
i0.Expression<int>? action,
|
|
||||||
i0.Expression<i3.Uint8List>? parameters,
|
|
||||||
i0.Expression<int>? sequence,
|
|
||||||
}) {
|
|
||||||
return i0.RawValuesInsertable({
|
|
||||||
if (id != null) 'id': id,
|
|
||||||
if (assetId != null) 'asset_id': assetId,
|
|
||||||
if (action != null) 'action': action,
|
|
||||||
if (parameters != null) 'parameters': parameters,
|
|
||||||
if (sequence != null) 'sequence': sequence,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
i1.AssetEditEntityCompanion copyWith({
|
|
||||||
i0.Value<String>? id,
|
|
||||||
i0.Value<String>? assetId,
|
|
||||||
i0.Value<i2.AssetEditAction>? action,
|
|
||||||
i0.Value<Map<String, Object?>>? parameters,
|
|
||||||
i0.Value<int>? sequence,
|
|
||||||
}) {
|
|
||||||
return i1.AssetEditEntityCompanion(
|
|
||||||
id: id ?? this.id,
|
|
||||||
assetId: assetId ?? this.assetId,
|
|
||||||
action: action ?? this.action,
|
|
||||||
parameters: parameters ?? this.parameters,
|
|
||||||
sequence: sequence ?? this.sequence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
|
||||||
final map = <String, i0.Expression>{};
|
|
||||||
if (id.present) {
|
|
||||||
map['id'] = i0.Variable<String>(id.value);
|
|
||||||
}
|
|
||||||
if (assetId.present) {
|
|
||||||
map['asset_id'] = i0.Variable<String>(assetId.value);
|
|
||||||
}
|
|
||||||
if (action.present) {
|
|
||||||
map['action'] = i0.Variable<int>(
|
|
||||||
i1.$AssetEditEntityTable.$converteraction.toSql(action.value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (parameters.present) {
|
|
||||||
map['parameters'] = i0.Variable<i3.Uint8List>(
|
|
||||||
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters.value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (sequence.present) {
|
|
||||||
map['sequence'] = i0.Variable<int>(sequence.value);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return (StringBuffer('AssetEditEntityCompanion(')
|
|
||||||
..write('id: $id, ')
|
|
||||||
..write('assetId: $assetId, ')
|
|
||||||
..write('action: $action, ')
|
|
||||||
..write('parameters: $parameters, ')
|
|
||||||
..write('sequence: $sequence')
|
|
||||||
..write(')'))
|
|
||||||
.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,8 +25,6 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
|||||||
|
|
||||||
RealColumn get longitude => real().nullable()();
|
RealColumn get longitude => real().nullable()();
|
||||||
|
|
||||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
@@ -45,7 +43,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
|||||||
width: width,
|
width: width,
|
||||||
remoteId: remoteId,
|
remoteId: remoteId,
|
||||||
orientation: orientation,
|
orientation: orientation,
|
||||||
playbackStyle: playbackStyle,
|
|
||||||
adjustmentTime: adjustmentTime,
|
adjustmentTime: adjustmentTime,
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
|
|||||||
i0.Value<DateTime?> adjustmentTime,
|
i0.Value<DateTime?> adjustmentTime,
|
||||||
i0.Value<double?> latitude,
|
i0.Value<double?> latitude,
|
||||||
i0.Value<double?> longitude,
|
i0.Value<double?> longitude,
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
|
||||||
});
|
});
|
||||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||||
i1.LocalAssetEntityCompanion Function({
|
i1.LocalAssetEntityCompanion Function({
|
||||||
@@ -44,7 +43,6 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
|||||||
i0.Value<DateTime?> adjustmentTime,
|
i0.Value<DateTime?> adjustmentTime,
|
||||||
i0.Value<double?> latitude,
|
i0.Value<double?> latitude,
|
||||||
i0.Value<double?> longitude,
|
i0.Value<double?> longitude,
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
class $$LocalAssetEntityTableFilterComposer
|
class $$LocalAssetEntityTableFilterComposer
|
||||||
@@ -131,16 +129,6 @@ class $$LocalAssetEntityTableFilterComposer
|
|||||||
column: $table.longitude,
|
column: $table.longitude,
|
||||||
builder: (column) => i0.ColumnFilters(column),
|
builder: (column) => i0.ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
i0.ColumnWithTypeConverterFilters<
|
|
||||||
i2.AssetPlaybackStyle,
|
|
||||||
i2.AssetPlaybackStyle,
|
|
||||||
int
|
|
||||||
>
|
|
||||||
get playbackStyle => $composableBuilder(
|
|
||||||
column: $table.playbackStyle,
|
|
||||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$LocalAssetEntityTableOrderingComposer
|
class $$LocalAssetEntityTableOrderingComposer
|
||||||
@@ -226,11 +214,6 @@ class $$LocalAssetEntityTableOrderingComposer
|
|||||||
column: $table.longitude,
|
column: $table.longitude,
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
builder: (column) => i0.ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
i0.ColumnOrderings<int> get playbackStyle => $composableBuilder(
|
|
||||||
column: $table.playbackStyle,
|
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$LocalAssetEntityTableAnnotationComposer
|
class $$LocalAssetEntityTableAnnotationComposer
|
||||||
@@ -294,12 +277,6 @@ class $$LocalAssetEntityTableAnnotationComposer
|
|||||||
|
|
||||||
i0.GeneratedColumn<double> get longitude =>
|
i0.GeneratedColumn<double> get longitude =>
|
||||||
$composableBuilder(column: $table.longitude, builder: (column) => column);
|
$composableBuilder(column: $table.longitude, builder: (column) => column);
|
||||||
|
|
||||||
i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
|
|
||||||
get playbackStyle => $composableBuilder(
|
|
||||||
column: $table.playbackStyle,
|
|
||||||
builder: (column) => column,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$LocalAssetEntityTableTableManager
|
class $$LocalAssetEntityTableTableManager
|
||||||
@@ -357,8 +334,6 @@ class $$LocalAssetEntityTableTableManager
|
|||||||
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
||||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
|
||||||
const i0.Value.absent(),
|
|
||||||
}) => i1.LocalAssetEntityCompanion(
|
}) => i1.LocalAssetEntityCompanion(
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
@@ -375,7 +350,6 @@ class $$LocalAssetEntityTableTableManager
|
|||||||
adjustmentTime: adjustmentTime,
|
adjustmentTime: adjustmentTime,
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
playbackStyle: playbackStyle,
|
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
({
|
({
|
||||||
@@ -394,8 +368,6 @@ class $$LocalAssetEntityTableTableManager
|
|||||||
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
||||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
|
||||||
const i0.Value.absent(),
|
|
||||||
}) => i1.LocalAssetEntityCompanion.insert(
|
}) => i1.LocalAssetEntityCompanion.insert(
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
@@ -412,7 +384,6 @@ class $$LocalAssetEntityTableTableManager
|
|||||||
adjustmentTime: adjustmentTime,
|
adjustmentTime: adjustmentTime,
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
playbackStyle: playbackStyle,
|
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||||
@@ -625,19 +596,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
|||||||
requiredDuringInsert: false,
|
requiredDuringInsert: false,
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
late final i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
|
|
||||||
playbackStyle =
|
|
||||||
i0.GeneratedColumn<int>(
|
|
||||||
'playback_style',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i0.DriftSqlType.int,
|
|
||||||
requiredDuringInsert: false,
|
|
||||||
defaultValue: const i4.Constant(0),
|
|
||||||
).withConverter<i2.AssetPlaybackStyle>(
|
|
||||||
i1.$LocalAssetEntityTable.$converterplaybackStyle,
|
|
||||||
);
|
|
||||||
@override
|
|
||||||
List<i0.GeneratedColumn> get $columns => [
|
List<i0.GeneratedColumn> get $columns => [
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
@@ -654,7 +612,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
|||||||
adjustmentTime,
|
adjustmentTime,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
playbackStyle,
|
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@@ -836,12 +793,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
|||||||
i0.DriftSqlType.double,
|
i0.DriftSqlType.double,
|
||||||
data['${effectivePrefix}longitude'],
|
data['${effectivePrefix}longitude'],
|
||||||
),
|
),
|
||||||
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromSql(
|
|
||||||
attachedDatabase.typeMapping.read(
|
|
||||||
i0.DriftSqlType.int,
|
|
||||||
data['${effectivePrefix}playback_style'],
|
|
||||||
)!,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,10 +803,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
|||||||
|
|
||||||
static i0.JsonTypeConverter2<i2.AssetType, int, int> $convertertype =
|
static i0.JsonTypeConverter2<i2.AssetType, int, int> $convertertype =
|
||||||
const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
|
const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
|
||||||
static i0.JsonTypeConverter2<i2.AssetPlaybackStyle, int, int>
|
|
||||||
$converterplaybackStyle = const i0.EnumIndexConverter<i2.AssetPlaybackStyle>(
|
|
||||||
i2.AssetPlaybackStyle.values,
|
|
||||||
);
|
|
||||||
@override
|
@override
|
||||||
bool get withoutRowId => true;
|
bool get withoutRowId => true;
|
||||||
@override
|
@override
|
||||||
@@ -879,7 +826,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
final DateTime? adjustmentTime;
|
final DateTime? adjustmentTime;
|
||||||
final double? latitude;
|
final double? latitude;
|
||||||
final double? longitude;
|
final double? longitude;
|
||||||
final i2.AssetPlaybackStyle playbackStyle;
|
|
||||||
const LocalAssetEntityData({
|
const LocalAssetEntityData({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.type,
|
required this.type,
|
||||||
@@ -896,7 +842,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
this.adjustmentTime,
|
this.adjustmentTime,
|
||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
required this.playbackStyle,
|
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||||
@@ -936,11 +881,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
if (!nullToAbsent || longitude != null) {
|
if (!nullToAbsent || longitude != null) {
|
||||||
map['longitude'] = i0.Variable<double>(longitude);
|
map['longitude'] = i0.Variable<double>(longitude);
|
||||||
}
|
}
|
||||||
{
|
|
||||||
map['playback_style'] = i0.Variable<int>(
|
|
||||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,9 +907,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
|
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
|
||||||
latitude: serializer.fromJson<double?>(json['latitude']),
|
latitude: serializer.fromJson<double?>(json['latitude']),
|
||||||
longitude: serializer.fromJson<double?>(json['longitude']),
|
longitude: serializer.fromJson<double?>(json['longitude']),
|
||||||
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
|
|
||||||
serializer.fromJson<int>(json['playbackStyle']),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
@@ -993,9 +930,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
|
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
|
||||||
'latitude': serializer.toJson<double?>(latitude),
|
'latitude': serializer.toJson<double?>(latitude),
|
||||||
'longitude': serializer.toJson<double?>(longitude),
|
'longitude': serializer.toJson<double?>(longitude),
|
||||||
'playbackStyle': serializer.toJson<int>(
|
|
||||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1015,7 +949,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
||||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||||
i2.AssetPlaybackStyle? playbackStyle,
|
|
||||||
}) => i1.LocalAssetEntityData(
|
}) => i1.LocalAssetEntityData(
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
@@ -1036,7 +969,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
: this.adjustmentTime,
|
: this.adjustmentTime,
|
||||||
latitude: latitude.present ? latitude.value : this.latitude,
|
latitude: latitude.present ? latitude.value : this.latitude,
|
||||||
longitude: longitude.present ? longitude.value : this.longitude,
|
longitude: longitude.present ? longitude.value : this.longitude,
|
||||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
|
||||||
);
|
);
|
||||||
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
||||||
return LocalAssetEntityData(
|
return LocalAssetEntityData(
|
||||||
@@ -1063,9 +995,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
: this.adjustmentTime,
|
: this.adjustmentTime,
|
||||||
latitude: data.latitude.present ? data.latitude.value : this.latitude,
|
latitude: data.latitude.present ? data.latitude.value : this.latitude,
|
||||||
longitude: data.longitude.present ? data.longitude.value : this.longitude,
|
longitude: data.longitude.present ? data.longitude.value : this.longitude,
|
||||||
playbackStyle: data.playbackStyle.present
|
|
||||||
? data.playbackStyle.value
|
|
||||||
: this.playbackStyle,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1086,8 +1015,7 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
..write('iCloudId: $iCloudId, ')
|
..write('iCloudId: $iCloudId, ')
|
||||||
..write('adjustmentTime: $adjustmentTime, ')
|
..write('adjustmentTime: $adjustmentTime, ')
|
||||||
..write('latitude: $latitude, ')
|
..write('latitude: $latitude, ')
|
||||||
..write('longitude: $longitude, ')
|
..write('longitude: $longitude')
|
||||||
..write('playbackStyle: $playbackStyle')
|
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -1109,7 +1037,6 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
adjustmentTime,
|
adjustmentTime,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
playbackStyle,
|
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -1129,8 +1056,7 @@ class LocalAssetEntityData extends i0.DataClass
|
|||||||
other.iCloudId == this.iCloudId &&
|
other.iCloudId == this.iCloudId &&
|
||||||
other.adjustmentTime == this.adjustmentTime &&
|
other.adjustmentTime == this.adjustmentTime &&
|
||||||
other.latitude == this.latitude &&
|
other.latitude == this.latitude &&
|
||||||
other.longitude == this.longitude &&
|
other.longitude == this.longitude);
|
||||||
other.playbackStyle == this.playbackStyle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalAssetEntityCompanion
|
class LocalAssetEntityCompanion
|
||||||
@@ -1150,7 +1076,6 @@ class LocalAssetEntityCompanion
|
|||||||
final i0.Value<DateTime?> adjustmentTime;
|
final i0.Value<DateTime?> adjustmentTime;
|
||||||
final i0.Value<double?> latitude;
|
final i0.Value<double?> latitude;
|
||||||
final i0.Value<double?> longitude;
|
final i0.Value<double?> longitude;
|
||||||
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
|
||||||
const LocalAssetEntityCompanion({
|
const LocalAssetEntityCompanion({
|
||||||
this.name = const i0.Value.absent(),
|
this.name = const i0.Value.absent(),
|
||||||
this.type = const i0.Value.absent(),
|
this.type = const i0.Value.absent(),
|
||||||
@@ -1167,7 +1092,6 @@ class LocalAssetEntityCompanion
|
|||||||
this.adjustmentTime = const i0.Value.absent(),
|
this.adjustmentTime = const i0.Value.absent(),
|
||||||
this.latitude = const i0.Value.absent(),
|
this.latitude = const i0.Value.absent(),
|
||||||
this.longitude = const i0.Value.absent(),
|
this.longitude = const i0.Value.absent(),
|
||||||
this.playbackStyle = const i0.Value.absent(),
|
|
||||||
});
|
});
|
||||||
LocalAssetEntityCompanion.insert({
|
LocalAssetEntityCompanion.insert({
|
||||||
required String name,
|
required String name,
|
||||||
@@ -1185,7 +1109,6 @@ class LocalAssetEntityCompanion
|
|||||||
this.adjustmentTime = const i0.Value.absent(),
|
this.adjustmentTime = const i0.Value.absent(),
|
||||||
this.latitude = const i0.Value.absent(),
|
this.latitude = const i0.Value.absent(),
|
||||||
this.longitude = const i0.Value.absent(),
|
this.longitude = const i0.Value.absent(),
|
||||||
this.playbackStyle = const i0.Value.absent(),
|
|
||||||
}) : name = i0.Value(name),
|
}) : name = i0.Value(name),
|
||||||
type = i0.Value(type),
|
type = i0.Value(type),
|
||||||
id = i0.Value(id);
|
id = i0.Value(id);
|
||||||
@@ -1205,7 +1128,6 @@ class LocalAssetEntityCompanion
|
|||||||
i0.Expression<DateTime>? adjustmentTime,
|
i0.Expression<DateTime>? adjustmentTime,
|
||||||
i0.Expression<double>? latitude,
|
i0.Expression<double>? latitude,
|
||||||
i0.Expression<double>? longitude,
|
i0.Expression<double>? longitude,
|
||||||
i0.Expression<int>? playbackStyle,
|
|
||||||
}) {
|
}) {
|
||||||
return i0.RawValuesInsertable({
|
return i0.RawValuesInsertable({
|
||||||
if (name != null) 'name': name,
|
if (name != null) 'name': name,
|
||||||
@@ -1223,7 +1145,6 @@ class LocalAssetEntityCompanion
|
|||||||
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
|
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
|
||||||
if (latitude != null) 'latitude': latitude,
|
if (latitude != null) 'latitude': latitude,
|
||||||
if (longitude != null) 'longitude': longitude,
|
if (longitude != null) 'longitude': longitude,
|
||||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1243,7 +1164,6 @@ class LocalAssetEntityCompanion
|
|||||||
i0.Value<DateTime?>? adjustmentTime,
|
i0.Value<DateTime?>? adjustmentTime,
|
||||||
i0.Value<double?>? latitude,
|
i0.Value<double?>? latitude,
|
||||||
i0.Value<double?>? longitude,
|
i0.Value<double?>? longitude,
|
||||||
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
|
||||||
}) {
|
}) {
|
||||||
return i1.LocalAssetEntityCompanion(
|
return i1.LocalAssetEntityCompanion(
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
@@ -1261,7 +1181,6 @@ class LocalAssetEntityCompanion
|
|||||||
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
||||||
latitude: latitude ?? this.latitude,
|
latitude: latitude ?? this.latitude,
|
||||||
longitude: longitude ?? this.longitude,
|
longitude: longitude ?? this.longitude,
|
||||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1315,13 +1234,6 @@ class LocalAssetEntityCompanion
|
|||||||
if (longitude.present) {
|
if (longitude.present) {
|
||||||
map['longitude'] = i0.Variable<double>(longitude.value);
|
map['longitude'] = i0.Variable<double>(longitude.value);
|
||||||
}
|
}
|
||||||
if (playbackStyle.present) {
|
|
||||||
map['playback_style'] = i0.Variable<int>(
|
|
||||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(
|
|
||||||
playbackStyle.value,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1342,8 +1254,7 @@ class LocalAssetEntityCompanion
|
|||||||
..write('iCloudId: $iCloudId, ')
|
..write('iCloudId: $iCloudId, ')
|
||||||
..write('adjustmentTime: $adjustmentTime, ')
|
..write('adjustmentTime: $adjustmentTime, ')
|
||||||
..write('latitude: $latitude, ')
|
..write('latitude: $latitude, ')
|
||||||
..write('longitude: $longitude, ')
|
..write('longitude: $longitude')
|
||||||
..write('playbackStyle: $playbackStyle')
|
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ SELECT
|
|||||||
NULL as latitude,
|
NULL as latitude,
|
||||||
NULL as longitude,
|
NULL as longitude,
|
||||||
NULL as adjustmentTime,
|
NULL as adjustmentTime,
|
||||||
rae.is_edited,
|
rae.is_edited
|
||||||
0 as playback_style
|
|
||||||
FROM
|
FROM
|
||||||
remote_asset_entity rae
|
remote_asset_entity rae
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
@@ -64,8 +63,7 @@ SELECT
|
|||||||
lae.latitude,
|
lae.latitude,
|
||||||
lae.longitude,
|
lae.longitude,
|
||||||
lae.adjustment_time,
|
lae.adjustment_time,
|
||||||
0 as is_edited,
|
0 as is_edited
|
||||||
lae.playback_style
|
|
||||||
FROM
|
FROM
|
||||||
local_asset_entity lae
|
local_asset_entity lae
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (
|
||||||
|
|||||||
+1
-4
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
|||||||
);
|
);
|
||||||
$arrayStartIndex += generatedlimit.amountOfVariables;
|
$arrayStartIndex += generatedlimit.amountOfVariables;
|
||||||
return customSelect(
|
return customSelect(
|
||||||
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
|
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||||
variables: [
|
variables: [
|
||||||
for (var $ in userIds) i0.Variable<String>($),
|
for (var $ in userIds) i0.Variable<String>($),
|
||||||
...generatedlimit.introducedVariables,
|
...generatedlimit.introducedVariables,
|
||||||
@@ -67,7 +67,6 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
|||||||
longitude: row.readNullable<double>('longitude'),
|
longitude: row.readNullable<double>('longitude'),
|
||||||
adjustmentTime: row.readNullable<DateTime>('adjustmentTime'),
|
adjustmentTime: row.readNullable<DateTime>('adjustmentTime'),
|
||||||
isEdited: row.read<bool>('is_edited'),
|
isEdited: row.read<bool>('is_edited'),
|
||||||
playbackStyle: row.read<int>('playback_style'),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -140,7 +139,6 @@ class MergedAssetResult {
|
|||||||
final double? longitude;
|
final double? longitude;
|
||||||
final DateTime? adjustmentTime;
|
final DateTime? adjustmentTime;
|
||||||
final bool isEdited;
|
final bool isEdited;
|
||||||
final int playbackStyle;
|
|
||||||
MergedAssetResult({
|
MergedAssetResult({
|
||||||
this.remoteId,
|
this.remoteId,
|
||||||
this.localId,
|
this.localId,
|
||||||
@@ -163,7 +161,6 @@ class MergedAssetResult {
|
|||||||
this.longitude,
|
this.longitude,
|
||||||
this.adjustmentTime,
|
this.adjustmentTime,
|
||||||
required this.isEdited,
|
required this.isEdited,
|
||||||
required this.playbackStyle,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntity
|
|||||||
|
|
||||||
IntColumn get source => intEnum<TrashOrigin>()();
|
IntColumn get source => intEnum<TrashOrigin>()();
|
||||||
|
|
||||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id, albumId};
|
Set<Column> get primaryKey => {id, albumId};
|
||||||
}
|
}
|
||||||
@@ -47,7 +45,6 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD
|
|||||||
height: height,
|
height: height,
|
||||||
width: width,
|
width: width,
|
||||||
orientation: orientation,
|
orientation: orientation,
|
||||||
playbackStyle: playbackStyle,
|
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder =
|
|||||||
i0.Value<bool> isFavorite,
|
i0.Value<bool> isFavorite,
|
||||||
i0.Value<int> orientation,
|
i0.Value<int> orientation,
|
||||||
required i3.TrashOrigin source,
|
required i3.TrashOrigin source,
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
|
||||||
});
|
});
|
||||||
typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
|
typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
|
||||||
i1.TrashedLocalAssetEntityCompanion Function({
|
i1.TrashedLocalAssetEntityCompanion Function({
|
||||||
@@ -40,7 +39,6 @@ typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
|
|||||||
i0.Value<bool> isFavorite,
|
i0.Value<bool> isFavorite,
|
||||||
i0.Value<int> orientation,
|
i0.Value<int> orientation,
|
||||||
i0.Value<i3.TrashOrigin> source,
|
i0.Value<i3.TrashOrigin> source,
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
class $$TrashedLocalAssetEntityTableFilterComposer
|
class $$TrashedLocalAssetEntityTableFilterComposer
|
||||||
@@ -119,16 +117,6 @@ class $$TrashedLocalAssetEntityTableFilterComposer
|
|||||||
column: $table.source,
|
column: $table.source,
|
||||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
i0.ColumnWithTypeConverterFilters<
|
|
||||||
i2.AssetPlaybackStyle,
|
|
||||||
i2.AssetPlaybackStyle,
|
|
||||||
int
|
|
||||||
>
|
|
||||||
get playbackStyle => $composableBuilder(
|
|
||||||
column: $table.playbackStyle,
|
|
||||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$TrashedLocalAssetEntityTableOrderingComposer
|
class $$TrashedLocalAssetEntityTableOrderingComposer
|
||||||
@@ -205,11 +193,6 @@ class $$TrashedLocalAssetEntityTableOrderingComposer
|
|||||||
column: $table.source,
|
column: $table.source,
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
builder: (column) => i0.ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
i0.ColumnOrderings<int> get playbackStyle => $composableBuilder(
|
|
||||||
column: $table.playbackStyle,
|
|
||||||
builder: (column) => i0.ColumnOrderings(column),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$TrashedLocalAssetEntityTableAnnotationComposer
|
class $$TrashedLocalAssetEntityTableAnnotationComposer
|
||||||
@@ -266,12 +249,6 @@ class $$TrashedLocalAssetEntityTableAnnotationComposer
|
|||||||
|
|
||||||
i0.GeneratedColumnWithTypeConverter<i3.TrashOrigin, int> get source =>
|
i0.GeneratedColumnWithTypeConverter<i3.TrashOrigin, int> get source =>
|
||||||
$composableBuilder(column: $table.source, builder: (column) => column);
|
$composableBuilder(column: $table.source, builder: (column) => column);
|
||||||
|
|
||||||
i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
|
|
||||||
get playbackStyle => $composableBuilder(
|
|
||||||
column: $table.playbackStyle,
|
|
||||||
builder: (column) => column,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$TrashedLocalAssetEntityTableTableManager
|
class $$TrashedLocalAssetEntityTableTableManager
|
||||||
@@ -333,8 +310,6 @@ class $$TrashedLocalAssetEntityTableTableManager
|
|||||||
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
||||||
i0.Value<int> orientation = const i0.Value.absent(),
|
i0.Value<int> orientation = const i0.Value.absent(),
|
||||||
i0.Value<i3.TrashOrigin> source = const i0.Value.absent(),
|
i0.Value<i3.TrashOrigin> source = const i0.Value.absent(),
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
|
||||||
const i0.Value.absent(),
|
|
||||||
}) => i1.TrashedLocalAssetEntityCompanion(
|
}) => i1.TrashedLocalAssetEntityCompanion(
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
@@ -349,7 +324,6 @@ class $$TrashedLocalAssetEntityTableTableManager
|
|||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
orientation: orientation,
|
orientation: orientation,
|
||||||
source: source,
|
source: source,
|
||||||
playbackStyle: playbackStyle,
|
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
({
|
({
|
||||||
@@ -366,8 +340,6 @@ class $$TrashedLocalAssetEntityTableTableManager
|
|||||||
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
||||||
i0.Value<int> orientation = const i0.Value.absent(),
|
i0.Value<int> orientation = const i0.Value.absent(),
|
||||||
required i3.TrashOrigin source,
|
required i3.TrashOrigin source,
|
||||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
|
||||||
const i0.Value.absent(),
|
|
||||||
}) => i1.TrashedLocalAssetEntityCompanion.insert(
|
}) => i1.TrashedLocalAssetEntityCompanion.insert(
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
@@ -382,7 +354,6 @@ class $$TrashedLocalAssetEntityTableTableManager
|
|||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
orientation: orientation,
|
orientation: orientation,
|
||||||
source: source,
|
source: source,
|
||||||
playbackStyle: playbackStyle,
|
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||||
@@ -579,19 +550,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
|||||||
i1.$TrashedLocalAssetEntityTable.$convertersource,
|
i1.$TrashedLocalAssetEntityTable.$convertersource,
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
late final i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
|
|
||||||
playbackStyle =
|
|
||||||
i0.GeneratedColumn<int>(
|
|
||||||
'playback_style',
|
|
||||||
aliasedName,
|
|
||||||
false,
|
|
||||||
type: i0.DriftSqlType.int,
|
|
||||||
requiredDuringInsert: false,
|
|
||||||
defaultValue: const i4.Constant(0),
|
|
||||||
).withConverter<i2.AssetPlaybackStyle>(
|
|
||||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle,
|
|
||||||
);
|
|
||||||
@override
|
|
||||||
List<i0.GeneratedColumn> get $columns => [
|
List<i0.GeneratedColumn> get $columns => [
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
@@ -606,7 +564,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
|||||||
isFavorite,
|
isFavorite,
|
||||||
orientation,
|
orientation,
|
||||||
source,
|
source,
|
||||||
playbackStyle,
|
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@@ -763,13 +720,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
|||||||
data['${effectivePrefix}source'],
|
data['${effectivePrefix}source'],
|
||||||
)!,
|
)!,
|
||||||
),
|
),
|
||||||
playbackStyle: i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle
|
|
||||||
.fromSql(
|
|
||||||
attachedDatabase.typeMapping.read(
|
|
||||||
i0.DriftSqlType.int,
|
|
||||||
data['${effectivePrefix}playback_style'],
|
|
||||||
)!,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,10 +732,6 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
|||||||
const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
|
const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
|
||||||
static i0.JsonTypeConverter2<i3.TrashOrigin, int, int> $convertersource =
|
static i0.JsonTypeConverter2<i3.TrashOrigin, int, int> $convertersource =
|
||||||
const i0.EnumIndexConverter<i3.TrashOrigin>(i3.TrashOrigin.values);
|
const i0.EnumIndexConverter<i3.TrashOrigin>(i3.TrashOrigin.values);
|
||||||
static i0.JsonTypeConverter2<i2.AssetPlaybackStyle, int, int>
|
|
||||||
$converterplaybackStyle = const i0.EnumIndexConverter<i2.AssetPlaybackStyle>(
|
|
||||||
i2.AssetPlaybackStyle.values,
|
|
||||||
);
|
|
||||||
@override
|
@override
|
||||||
bool get withoutRowId => true;
|
bool get withoutRowId => true;
|
||||||
@override
|
@override
|
||||||
@@ -807,7 +753,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
|||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
final int orientation;
|
final int orientation;
|
||||||
final i3.TrashOrigin source;
|
final i3.TrashOrigin source;
|
||||||
final i2.AssetPlaybackStyle playbackStyle;
|
|
||||||
const TrashedLocalAssetEntityData({
|
const TrashedLocalAssetEntityData({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.type,
|
required this.type,
|
||||||
@@ -822,7 +767,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
|||||||
required this.isFavorite,
|
required this.isFavorite,
|
||||||
required this.orientation,
|
required this.orientation,
|
||||||
required this.source,
|
required this.source,
|
||||||
required this.playbackStyle,
|
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||||
@@ -856,13 +800,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
|||||||
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source),
|
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
{
|
|
||||||
map['playback_style'] = i0.Variable<int>(
|
|
||||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toSql(
|
|
||||||
playbackStyle,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -889,8 +826,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
|||||||
source: i1.$TrashedLocalAssetEntityTable.$convertersource.fromJson(
|
source: i1.$TrashedLocalAssetEntityTable.$convertersource.fromJson(
|
||||||
serializer.fromJson<int>(json['source']),
|
serializer.fromJson<int>(json['source']),
|
||||||
),
|
),
|
||||||
playbackStyle: i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle
|
|
||||||
.fromJson(serializer.fromJson<int>(json['playbackStyle'])),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
@@ -914,11 +849,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
|||||||
'source': serializer.toJson<int>(
|
'source': serializer.toJson<int>(
|
||||||
i1.$TrashedLocalAssetEntityTable.$convertersource.toJson(source),
|
i1.$TrashedLocalAssetEntityTable.$convertersource.toJson(source),
|
||||||
),
|
),
|
||||||
'playbackStyle': serializer.toJson<int>(
|
|
||||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toJson(
|
|
||||||
playbackStyle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,7 +866,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
|||||||
bool? isFavorite,
|
bool? isFavorite,
|
||||||
int? orientation,
|
int? orientation,
|
||||||
i3.TrashOrigin? source,
|
i3.TrashOrigin? source,
|
||||||
i2.AssetPlaybackStyle? playbackStyle,
|
|
||||||
}) => i1.TrashedLocalAssetEntityData(
|
}) => i1.TrashedLocalAssetEntityData(
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
@@ -953,7 +882,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
|||||||
isFavorite: isFavorite ?? this.isFavorite,
|
isFavorite: isFavorite ?? this.isFavorite,
|
||||||
orientation: orientation ?? this.orientation,
|
orientation: orientation ?? this.orientation,
|
||||||
source: source ?? this.source,
|
source: source ?? this.source,
|
||||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
|
||||||
);
|
);
|
||||||
TrashedLocalAssetEntityData copyWithCompanion(
|
TrashedLocalAssetEntityData copyWithCompanion(
|
||||||
i1.TrashedLocalAssetEntityCompanion data,
|
i1.TrashedLocalAssetEntityCompanion data,
|
||||||
@@ -978,9 +906,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
|||||||
? data.orientation.value
|
? data.orientation.value
|
||||||
: this.orientation,
|
: this.orientation,
|
||||||
source: data.source.present ? data.source.value : this.source,
|
source: data.source.present ? data.source.value : this.source,
|
||||||
playbackStyle: data.playbackStyle.present
|
|
||||||
? data.playbackStyle.value
|
|
||||||
: this.playbackStyle,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -999,8 +924,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
|||||||
..write('checksum: $checksum, ')
|
..write('checksum: $checksum, ')
|
||||||
..write('isFavorite: $isFavorite, ')
|
..write('isFavorite: $isFavorite, ')
|
||||||
..write('orientation: $orientation, ')
|
..write('orientation: $orientation, ')
|
||||||
..write('source: $source, ')
|
..write('source: $source')
|
||||||
..write('playbackStyle: $playbackStyle')
|
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
@@ -1020,7 +944,6 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
|||||||
isFavorite,
|
isFavorite,
|
||||||
orientation,
|
orientation,
|
||||||
source,
|
source,
|
||||||
playbackStyle,
|
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -1038,8 +961,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
|||||||
other.checksum == this.checksum &&
|
other.checksum == this.checksum &&
|
||||||
other.isFavorite == this.isFavorite &&
|
other.isFavorite == this.isFavorite &&
|
||||||
other.orientation == this.orientation &&
|
other.orientation == this.orientation &&
|
||||||
other.source == this.source &&
|
other.source == this.source);
|
||||||
other.playbackStyle == this.playbackStyle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TrashedLocalAssetEntityCompanion
|
class TrashedLocalAssetEntityCompanion
|
||||||
@@ -1057,7 +979,6 @@ class TrashedLocalAssetEntityCompanion
|
|||||||
final i0.Value<bool> isFavorite;
|
final i0.Value<bool> isFavorite;
|
||||||
final i0.Value<int> orientation;
|
final i0.Value<int> orientation;
|
||||||
final i0.Value<i3.TrashOrigin> source;
|
final i0.Value<i3.TrashOrigin> source;
|
||||||
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
|
||||||
const TrashedLocalAssetEntityCompanion({
|
const TrashedLocalAssetEntityCompanion({
|
||||||
this.name = const i0.Value.absent(),
|
this.name = const i0.Value.absent(),
|
||||||
this.type = const i0.Value.absent(),
|
this.type = const i0.Value.absent(),
|
||||||
@@ -1072,7 +993,6 @@ class TrashedLocalAssetEntityCompanion
|
|||||||
this.isFavorite = const i0.Value.absent(),
|
this.isFavorite = const i0.Value.absent(),
|
||||||
this.orientation = const i0.Value.absent(),
|
this.orientation = const i0.Value.absent(),
|
||||||
this.source = const i0.Value.absent(),
|
this.source = const i0.Value.absent(),
|
||||||
this.playbackStyle = const i0.Value.absent(),
|
|
||||||
});
|
});
|
||||||
TrashedLocalAssetEntityCompanion.insert({
|
TrashedLocalAssetEntityCompanion.insert({
|
||||||
required String name,
|
required String name,
|
||||||
@@ -1088,7 +1008,6 @@ class TrashedLocalAssetEntityCompanion
|
|||||||
this.isFavorite = const i0.Value.absent(),
|
this.isFavorite = const i0.Value.absent(),
|
||||||
this.orientation = const i0.Value.absent(),
|
this.orientation = const i0.Value.absent(),
|
||||||
required i3.TrashOrigin source,
|
required i3.TrashOrigin source,
|
||||||
this.playbackStyle = const i0.Value.absent(),
|
|
||||||
}) : name = i0.Value(name),
|
}) : name = i0.Value(name),
|
||||||
type = i0.Value(type),
|
type = i0.Value(type),
|
||||||
id = i0.Value(id),
|
id = i0.Value(id),
|
||||||
@@ -1108,7 +1027,6 @@ class TrashedLocalAssetEntityCompanion
|
|||||||
i0.Expression<bool>? isFavorite,
|
i0.Expression<bool>? isFavorite,
|
||||||
i0.Expression<int>? orientation,
|
i0.Expression<int>? orientation,
|
||||||
i0.Expression<int>? source,
|
i0.Expression<int>? source,
|
||||||
i0.Expression<int>? playbackStyle,
|
|
||||||
}) {
|
}) {
|
||||||
return i0.RawValuesInsertable({
|
return i0.RawValuesInsertable({
|
||||||
if (name != null) 'name': name,
|
if (name != null) 'name': name,
|
||||||
@@ -1124,7 +1042,6 @@ class TrashedLocalAssetEntityCompanion
|
|||||||
if (isFavorite != null) 'is_favorite': isFavorite,
|
if (isFavorite != null) 'is_favorite': isFavorite,
|
||||||
if (orientation != null) 'orientation': orientation,
|
if (orientation != null) 'orientation': orientation,
|
||||||
if (source != null) 'source': source,
|
if (source != null) 'source': source,
|
||||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1142,7 +1059,6 @@ class TrashedLocalAssetEntityCompanion
|
|||||||
i0.Value<bool>? isFavorite,
|
i0.Value<bool>? isFavorite,
|
||||||
i0.Value<int>? orientation,
|
i0.Value<int>? orientation,
|
||||||
i0.Value<i3.TrashOrigin>? source,
|
i0.Value<i3.TrashOrigin>? source,
|
||||||
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
|
||||||
}) {
|
}) {
|
||||||
return i1.TrashedLocalAssetEntityCompanion(
|
return i1.TrashedLocalAssetEntityCompanion(
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
@@ -1158,7 +1074,6 @@ class TrashedLocalAssetEntityCompanion
|
|||||||
isFavorite: isFavorite ?? this.isFavorite,
|
isFavorite: isFavorite ?? this.isFavorite,
|
||||||
orientation: orientation ?? this.orientation,
|
orientation: orientation ?? this.orientation,
|
||||||
source: source ?? this.source,
|
source: source ?? this.source,
|
||||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,13 +1123,6 @@ class TrashedLocalAssetEntityCompanion
|
|||||||
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source.value),
|
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source.value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (playbackStyle.present) {
|
|
||||||
map['playback_style'] = i0.Variable<int>(
|
|
||||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toSql(
|
|
||||||
playbackStyle.value,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1233,8 +1141,7 @@ class TrashedLocalAssetEntityCompanion
|
|||||||
..write('checksum: $checksum, ')
|
..write('checksum: $checksum, ')
|
||||||
..write('isFavorite: $isFavorite, ')
|
..write('isFavorite: $isFavorite, ')
|
||||||
..write('orientation: $orientation, ')
|
..write('orientation: $orientation, ')
|
||||||
..write('source: $source, ')
|
..write('source: $source')
|
||||||
..write('playbackStyle: $playbackStyle')
|
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user