mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbdbd291ba | |||
| 0ab057f453 | |||
| 6c531e0a5a | |||
| 471c27cd33 | |||
| 4773788a88 | |||
| d49d995611 | |||
| 0ac3d6a83a | |||
| 9996ee12d0 | |||
| 0a79dd1228 | |||
| e45308b949 | |||
| c403e03a42 | |||
| e7db3b220d | |||
| 28d5c169c0 | |||
| 0f2fe656db | |||
| 34ce68095d | |||
| 8764a1894b | |||
| 27f69b39b2 | |||
| 9fc6fbc373 | |||
| 9fc32b6f7a | |||
| 4571940a4e | |||
| 1ceb6d2e21 | |||
| 1a4c5d73ac | |||
| 22b43bf4d9 | |||
| 45eff1c663 | |||
| 56b8e1b8a9 | |||
| f79c8cf1c1 | |||
| 8e50d25f45 | |||
| 8222781d1f | |||
| 08c4594cde | |||
| d325231df2 | |||
| f2726606e0 | |||
| 0edbca24e4 | |||
| 4791d9c0c3 | |||
| a47b232235 | |||
| df0c86920d | |||
| 422111d26e | |||
| 7a83baaf27 | |||
| aaf34fa7d4 | |||
| 4a384bca86 | |||
| dd72ec2621 | |||
| e73686bd76 |
@@ -131,7 +131,7 @@ jobs:
|
|||||||
- device: rocm
|
- device: rocm
|
||||||
suffixes: '-rocm'
|
suffixes: '-rocm'
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
runner-mapping: '{"linux/amd64": "pokedex-giant"}'
|
runner-mapping: '{"linux/amd64": "pokedex-large"}'
|
||||||
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
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^24.10.14",
|
"@types/node": "^24.11.0",
|
||||||
"@vitest/coverage-v8": "^4.0.0",
|
"@vitest/coverage-v8": "^4.0.0",
|
||||||
"byte-size": "^9.0.0",
|
"byte-size": "^9.0.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||||
user: '1000:1000'
|
user: '1000:1000'
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -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`, `libopus`.
|
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`.
|
||||||
|
|
||||||
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", "libopus"],
|
"acceptedAudioCodecs": ["aac", "mp3", "opus"],
|
||||||
"acceptedContainers": ["mov", "ogg", "webm"],
|
"acceptedContainers": ["mov", "ogg", "webm"],
|
||||||
"acceptedVideoCodecs": ["h264"],
|
"acceptedVideoCodecs": ["h264"],
|
||||||
"bframes": -1,
|
"bframes": -1,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich-e2e-redis
|
container_name: immich-e2e-redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@
|
|||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@socket.io/component-emitter": "^3.1.2",
|
"@socket.io/component-emitter": "^3.1.2",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^24.10.14",
|
"@types/node": "^24.11.0",
|
||||||
"@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",
|
||||||
|
|||||||
@@ -99,13 +99,13 @@ export const setupTimelineMockApiRoutes = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail|fullsize)/;
|
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||||
const match = request.url().match(pattern);
|
const match = request.url().match(pattern);
|
||||||
if (!match?.groups) {
|
if (!match?.groups) {
|
||||||
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (match.groups.size === 'preview' || match.groups.size === 'fullsize') {
|
if (match.groups.size === 'preview') {
|
||||||
if (!route.request().serviceWorker()) {
|
if (!route.request().serviceWorker()) {
|
||||||
return route.continue();
|
return route.continue();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ test.describe('broken-asset responsiveness', () => {
|
|||||||
|
|
||||||
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
|
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
|
||||||
await context.route(
|
await context.route(
|
||||||
(url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
|
(url) =>
|
||||||
|
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
|
||||||
|
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
|
||||||
async (route) => {
|
async (route) => {
|
||||||
return route.fulfill({ status: 404 });
|
return route.fulfill({ status: 404 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -54,7 +53,7 @@ test.describe('search gallery-viewer', () => {
|
|||||||
assets: {
|
assets: {
|
||||||
total: searchAssets.length,
|
total: searchAssets.length,
|
||||||
count: searchAssets.length,
|
count: searchAssets.length,
|
||||||
items: searchAssets.map((asset) => toAssetResponseDto(asset)),
|
items: searchAssets,
|
||||||
facets: [],
|
facets: [],
|
||||||
nextPage: null,
|
nextPage: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -163,11 +163,13 @@ export const assetViewerUtils = {
|
|||||||
return page.locator('#immich-asset-viewer');
|
return page.locator('#immich-asset-viewer');
|
||||||
},
|
},
|
||||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||||
const previewUrl = `/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true`;
|
|
||||||
await page
|
await page
|
||||||
.getByTestId('preview')
|
.locator(
|
||||||
.and(page.locator(`[src="${previewUrl}"]`))
|
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
||||||
.or(page.locator(`video[poster="${previewUrl}"]`))
|
)
|
||||||
|
.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) {
|
||||||
|
|||||||
+3
-1
@@ -1007,6 +1007,8 @@
|
|||||||
"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",
|
||||||
@@ -1072,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",
|
||||||
|
|||||||
@@ -64,14 +64,6 @@ 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
|
||||||
@@ -102,12 +94,19 @@ 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":
|
||||||
openvino_dir = self.model_path.parent / "openvino"
|
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
||||||
device = f"GPU.{settings.device_id}"
|
# Check for available devices, preferring GPU over CPU
|
||||||
|
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,
|
"device_type": device_type,
|
||||||
"precision": settings.openvino_precision.value,
|
"precision": settings.openvino_precision.value,
|
||||||
"cache_dir": openvino_dir.as_posix(),
|
"cache_dir": (self.model_path.parent / "openvino").as_posix(),
|
||||||
}
|
}
|
||||||
case "CoreMLExecutionProvider":
|
case "CoreMLExecutionProvider":
|
||||||
options = {
|
options = {
|
||||||
@@ -139,12 +138,14 @@ 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"]:
|
||||||
|
|||||||
@@ -204,13 +204,6 @@ 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")
|
||||||
@@ -256,7 +249,8 @@ class TestOrtSession:
|
|||||||
{"arena_extend_strategy": "kSameAsRequested"},
|
{"arena_extend_strategy": "kSameAsRequested"},
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_sets_provider_options_for_openvino(self) -> None:
|
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
|
||||||
|
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"
|
||||||
|
|
||||||
@@ -270,7 +264,8 @@ class TestOrtSession:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None:
|
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
|
||||||
|
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)
|
||||||
@@ -285,6 +280,19 @@ 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"
|
||||||
|
|
||||||
@@ -341,6 +349,23 @@ 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"])
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ import app.alextran.immich.BuildConfig
|
|||||||
import app.alextran.immich.NativeBuffer
|
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.Headers
|
||||||
import okhttp3.Credentials
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.json.JSONObject
|
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.Socket
|
import java.net.Socket
|
||||||
@@ -32,7 +37,19 @@ private const val CERT_ALIAS = "client_cert"
|
|||||||
private const val PREFS_NAME = "immich.ssl"
|
private const val PREFS_NAME = "immich.ssl"
|
||||||
private const val PREFS_CERT_ALIAS = "immich.client_cert"
|
private const val PREFS_CERT_ALIAS = "immich.client_cert"
|
||||||
private const val PREFS_HEADERS = "immich.request_headers"
|
private const val PREFS_HEADERS = "immich.request_headers"
|
||||||
private const val PREFS_SERVER_URL = "immich.server_url"
|
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.
|
||||||
@@ -58,6 +75,8 @@ object HttpClientManager {
|
|||||||
var headers: Headers = Headers.headersOf()
|
var headers: Headers = Headers.headersOf()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
private val cookieJar = PersistentCookieJar()
|
||||||
|
|
||||||
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
|
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
|
||||||
|
|
||||||
fun initialize(context: Context) {
|
fun initialize(context: Context) {
|
||||||
@@ -69,16 +88,23 @@ object HttpClientManager {
|
|||||||
prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
|
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
|
||||||
|
|
||||||
|
cookieJar.init(prefs)
|
||||||
|
|
||||||
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
|
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
|
||||||
if (savedHeaders != null) {
|
if (savedHeaders != null) {
|
||||||
val json = JSONObject(savedHeaders)
|
val map = Json.decodeFromString<Map<String, String>>(savedHeaders)
|
||||||
val builder = Headers.Builder()
|
val builder = Headers.Builder()
|
||||||
for (key in json.keys()) {
|
for ((key, value) in map) {
|
||||||
builder.add(key, json.getString(key))
|
builder.add(key, value)
|
||||||
}
|
}
|
||||||
headers = builder.build()
|
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)
|
||||||
initialized = true
|
initialized = true
|
||||||
@@ -153,23 +179,48 @@ object HttpClientManager {
|
|||||||
synchronized(this) { clientChangedListeners.add(listener) }
|
synchronized(this) { clientChangedListeners.add(listener) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>) {
|
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>, token: String?) {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
val builder = Headers.Builder()
|
val builder = Headers.Builder()
|
||||||
headerMap.forEach { (key, value) -> builder[key] = value }
|
headerMap.forEach { (key, value) -> builder[key] = value }
|
||||||
val newHeaders = builder.build()
|
val newHeaders = builder.build()
|
||||||
|
|
||||||
val headersChanged = headers != newHeaders
|
val headersChanged = headers != newHeaders
|
||||||
val newUrl = serverUrls.firstOrNull()
|
val urlsChanged = Json.encodeToString(serverUrls) != prefs.getString(PREFS_SERVER_URLS, null)
|
||||||
val urlChanged = newUrl != prefs.getString(PREFS_SERVER_URL, null)
|
|
||||||
if (!headersChanged && !urlChanged) return
|
|
||||||
headers = newHeaders
|
headers = newHeaders
|
||||||
|
cookieJar.setServerUrls(serverUrls)
|
||||||
|
|
||||||
|
if (headersChanged || urlsChanged) {
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
if (headersChanged) putString(PREFS_HEADERS, JSONObject(headerMap).toString())
|
putString(PREFS_HEADERS, Json.encodeToString(headerMap))
|
||||||
if (urlChanged) {
|
putString(PREFS_SERVER_URLS, Json.encodeToString(serverUrls))
|
||||||
if (newUrl != null) putString(PREFS_SERVER_URL, newUrl) else remove(PREFS_SERVER_URL)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun build(cacheDir: File): OkHttpClient {
|
private fun build(cacheDir: File): OkHttpClient {
|
||||||
@@ -188,6 +239,7 @@ object HttpClientManager {
|
|||||||
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||||
|
|
||||||
return OkHttpClient.Builder()
|
return OkHttpClient.Builder()
|
||||||
|
.cookieJar(cookieJar)
|
||||||
.addInterceptor {
|
.addInterceptor {
|
||||||
val request = it.request()
|
val request = it.request()
|
||||||
val builder = request.newBuilder()
|
val builder = request.newBuilder()
|
||||||
@@ -249,4 +301,131 @@ 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ interface NetworkApi {
|
|||||||
fun removeCertificate(callback: (Result<Unit>) -> Unit)
|
fun removeCertificate(callback: (Result<Unit>) -> Unit)
|
||||||
fun hasCertificate(): Boolean
|
fun hasCertificate(): Boolean
|
||||||
fun getClientPointer(): Long
|
fun getClientPointer(): Long
|
||||||
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>)
|
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. */
|
||||||
@@ -287,8 +287,9 @@ interface NetworkApi {
|
|||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val headersArg = args[0] as Map<String, String>
|
val headersArg = args[0] as Map<String, String>
|
||||||
val serverUrlsArg = args[1] as List<String>
|
val serverUrlsArg = args[1] as List<String>
|
||||||
|
val tokenArg = args[2] as String?
|
||||||
val wrapped: List<Any?> = try {
|
val wrapped: List<Any?> = try {
|
||||||
api.setRequestHeaders(headersArg, serverUrlsArg)
|
api.setRequestHeaders(headersArg, serverUrlsArg, tokenArg)
|
||||||
listOf(null)
|
listOf(null)
|
||||||
} catch (exception: Throwable) {
|
} catch (exception: Throwable) {
|
||||||
NetworkPigeonUtils.wrapError(exception)
|
NetworkPigeonUtils.wrapError(exception)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NetworkApiImpl() : NetworkApi {
|
private class NetworkApiImpl : NetworkApi {
|
||||||
var activity: Activity? = null
|
var activity: Activity? = null
|
||||||
|
|
||||||
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
|
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
|
||||||
@@ -79,7 +79,7 @@ private class NetworkApiImpl() : NetworkApi {
|
|||||||
return HttpClientManager.getClientPointer()
|
return HttpClientManager.getClientPointer()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>) {
|
override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>, token: String?) {
|
||||||
HttpClientManager.setRequestHeaders(headers, serverUrls)
|
HttpClientManager.setRequestHeaders(headers, serverUrls, token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
|
|||||||
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
||||||
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
|
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
|
||||||
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
||||||
|
HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) }
|
||||||
url.toHttpUrlOrNull()?.let { httpUrl ->
|
url.toHttpUrlOrNull()?.let { httpUrl ->
|
||||||
if (httpUrl.username.isNotEmpty()) {
|
if (httpUrl.username.isNotEmpty()) {
|
||||||
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
|
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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
|
||||||
@@ -81,11 +82,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
}
|
}
|
||||||
if (hasSpecialFormatColumn()) {
|
if (hasSpecialFormatColumn()) {
|
||||||
add(SPECIAL_FORMAT_COLUMN)
|
add(SPECIAL_FORMAT_COLUMN)
|
||||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
} else {
|
||||||
// Fallback: read XMP from MediaStore to detect Motion Photos
|
// fallback to mimetype and xmp for playback style detection on older Android versions
|
||||||
// only needed if SPECIAL_FORMAT column isn't available
|
// both only needed if special format column is not available
|
||||||
|
add(MediaStore.MediaColumns.MIME_TYPE)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
add(MediaStore.MediaColumns.XMP)
|
add(MediaStore.MediaColumns.XMP)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
|
|
||||||
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
||||||
@@ -131,6 +135,7 @@ 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)
|
||||||
@@ -177,19 +182,20 @@ 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, specialFormatColumn, xmpColumn, c
|
numericId, rawMediaType, mimeTypeColumn, 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,
|
||||||
width,
|
if (isFlipped) height else width,
|
||||||
height,
|
if (isFlipped) width else height,
|
||||||
duration,
|
duration,
|
||||||
orientation.toLong(),
|
0L,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
playbackStyle = playbackStyle,
|
playbackStyle = playbackStyle,
|
||||||
)
|
)
|
||||||
@@ -200,13 +206,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects the playback style for an asset using _special_format (API 33+)
|
* Detects the playback style for an asset using _special_format (SDK Extension 21+)
|
||||||
* or XMP / MIME / RIFF header fallbacks (pre-33).
|
* or XMP / MIME / RIFF header fallbacks.
|
||||||
*/
|
*/
|
||||||
@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
|
||||||
@@ -231,45 +238,55 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
return PlatformAssetPlaybackStyle.UNKNOWN
|
return PlatformAssetPlaybackStyle.UNKNOWN
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-API 33 fallback
|
val mimeType = if (mimeTypeColumn != -1) cursor.getString(mimeTypeColumn) else null
|
||||||
|
|
||||||
|
// 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
|
||||||
)
|
)
|
||||||
|
|
||||||
// Read XMP from cursor (API 30+) or ExifInterface stream (pre-30)
|
// Only WebP needs a stream check to distinguish static vs animated;
|
||||||
val xmp: String? = if (xmpColumn != -1) {
|
// WebP files are not used as motion photos, so skip XMP detection
|
||||||
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
|
if (mimeType == "image/webp") {
|
||||||
} else {
|
|
||||||
try {
|
try {
|
||||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
|
||||||
ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xmp != null && "Camera:MotionPhoto" in xmp) {
|
|
||||||
return PlatformAssetPlaybackStyle.LIVE_PHOTO
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
|
||||||
val glide = Glide.get(ctx)
|
val glide = Glide.get(ctx)
|
||||||
|
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
val type = ImageHeaderParserUtils.getType(
|
val type = ImageHeaderParserUtils.getType(
|
||||||
glide.registry.imageHeaderParsers,
|
listOf(DefaultImageHeaderParser()),
|
||||||
stream,
|
stream,
|
||||||
glide.arrayPool
|
glide.arrayPool
|
||||||
)
|
)
|
||||||
if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) {
|
// 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
|
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
|
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) {
|
||||||
|
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
|
||||||
|
} else {
|
||||||
|
// if xmp column is not available, we are on API 29 or below
|
||||||
|
// theoretically there were motion photos but the Camera:MotionPhoto xmp tag
|
||||||
|
// was only added in Android 11, so we should not have to worry about parsing XMP on older versions
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xmp != null && "Camera:MotionPhoto" in xmp) {
|
||||||
|
return PlatformAssetPlaybackStyle.LIVE_PHOTO
|
||||||
|
}
|
||||||
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE
|
return PlatformAssetPlaybackStyle.IMAGE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ protocol NetworkApi {
|
|||||||
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
|
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
func hasCertificate() throws -> Bool
|
func hasCertificate() throws -> Bool
|
||||||
func getClientPointer() throws -> Int64
|
func getClientPointer() throws -> Int64
|
||||||
func setRequestHeaders(headers: [String: String], serverUrls: [String]) throws
|
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`.
|
||||||
@@ -315,8 +315,9 @@ class NetworkApiSetup {
|
|||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let headersArg = args[0] as! [String: String]
|
let headersArg = args[0] as! [String: String]
|
||||||
let serverUrlsArg = args[1] as! [String]
|
let serverUrlsArg = args[1] as! [String]
|
||||||
|
let tokenArg: String? = nilOrValue(args[2])
|
||||||
do {
|
do {
|
||||||
try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg)
|
try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg, token: tokenArg)
|
||||||
reply(wrapResult(nil))
|
reply(wrapResult(nil))
|
||||||
} catch {
|
} catch {
|
||||||
reply(wrapError(error))
|
reply(wrapError(error))
|
||||||
|
|||||||
@@ -58,42 +58,39 @@ class NetworkApiImpl: NetworkApi {
|
|||||||
return Int64(Int(bitPattern: pointer))
|
return Int64(Int(bitPattern: pointer))
|
||||||
}
|
}
|
||||||
|
|
||||||
func setRequestHeaders(headers: [String : String], serverUrls: [String]) throws {
|
func setRequestHeaders(headers: [String : String], serverUrls: [String], token: String?) throws {
|
||||||
var headers = headers
|
URLSessionManager.setServerUrls(serverUrls)
|
||||||
if let token = headers.removeValue(forKey: "x-immich-user-token") {
|
|
||||||
|
if let token = token {
|
||||||
|
let expiry = Date().addingTimeInterval(COOKIE_EXPIRY_DAYS * 24 * 60 * 60)
|
||||||
for serverUrl in serverUrls {
|
for serverUrl in serverUrls {
|
||||||
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
|
guard let url = URL(string: serverUrl), let domain = url.host else { continue }
|
||||||
let isSecure = serverUrl.hasPrefix("https")
|
let isSecure = serverUrl.hasPrefix("https")
|
||||||
let cookies: [(String, String, Bool)] = [
|
let values: [AuthCookie: String] = [
|
||||||
("immich_access_token", token, true),
|
.accessToken: token,
|
||||||
("immich_is_authenticated", "true", false),
|
.isAuthenticated: "true",
|
||||||
("immich_auth_type", "password", true),
|
.authType: "password",
|
||||||
]
|
]
|
||||||
let expiry = Date().addingTimeInterval(400 * 24 * 60 * 60)
|
for (cookie, value) in values {
|
||||||
for (name, value, httpOnly) in cookies {
|
|
||||||
var properties: [HTTPCookiePropertyKey: Any] = [
|
var properties: [HTTPCookiePropertyKey: Any] = [
|
||||||
.name: name,
|
.name: cookie.name,
|
||||||
.value: value,
|
.value: value,
|
||||||
.domain: domain,
|
.domain: domain,
|
||||||
.path: "/",
|
.path: "/",
|
||||||
.expires: expiry,
|
.expires: expiry,
|
||||||
]
|
]
|
||||||
if isSecure { properties[.secure] = "TRUE" }
|
if isSecure { properties[.secure] = "TRUE" }
|
||||||
if httpOnly { properties[.init("HttpOnly")] = "TRUE" }
|
if cookie.httpOnly { properties[.init("HttpOnly")] = "TRUE" }
|
||||||
if let cookie = HTTPCookie(properties: properties) {
|
if let httpCookie = HTTPCookie(properties: properties) {
|
||||||
URLSessionManager.cookieStorage.setCookie(cookie)
|
URLSessionManager.cookieStorage.setCookie(httpCookie)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if serverUrls.first != UserDefaults.group.string(forKey: SERVER_URL_KEY) {
|
|
||||||
UserDefaults.group.set(serverUrls.first, forKey: SERVER_URL_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] {
|
if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] {
|
||||||
UserDefaults.group.set(headers, forKey: HEADERS_KEY)
|
UserDefaults.group.set(headers, forKey: HEADERS_KEY)
|
||||||
URLSessionManager.shared.recreateSession() // Recreate session to apply custom headers without app restart
|
URLSessionManager.shared.recreateSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,30 @@ 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 HEADERS_KEY = "immich.request_headers"
|
||||||
let SERVER_URL_KEY = "immich.server_url"
|
let SERVER_URLS_KEY = "immich.server_urls"
|
||||||
let APP_GROUP = "group.app.immich.share"
|
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 {
|
extension UserDefaults {
|
||||||
static let group = UserDefaults(suiteName: APP_GROUP)!
|
static let group = UserDefaults(suiteName: APP_GROUP)!
|
||||||
@@ -34,6 +56,8 @@ class URLSessionManager: NSObject {
|
|||||||
return "Immich_iOS_\(version)"
|
return "Immich_iOS_\(version)"
|
||||||
}()
|
}()
|
||||||
static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP)
|
static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP)
|
||||||
|
private static var serverUrls: [String] = []
|
||||||
|
private static var isSyncing = false
|
||||||
|
|
||||||
var sessionPointer: UnsafeMutableRawPointer {
|
var sessionPointer: UnsafeMutableRawPointer {
|
||||||
Unmanaged.passUnretained(session).toOpaque()
|
Unmanaged.passUnretained(session).toOpaque()
|
||||||
@@ -43,12 +67,83 @@ class URLSessionManager: NSObject {
|
|||||||
delegate = URLSessionManagerDelegate()
|
delegate = URLSessionManagerDelegate()
|
||||||
session = Self.buildSession(delegate: delegate)
|
session = Self.buildSession(delegate: delegate)
|
||||||
super.init()
|
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() {
|
func recreateSession() {
|
||||||
session = Self.buildSession(delegate: delegate)
|
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 {
|
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
|
||||||
let config = URLSessionConfiguration.default
|
let config = URLSessionConfiguration.default
|
||||||
config.urlCache = urlCache
|
config.urlCache = urlCache
|
||||||
|
|||||||
@@ -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.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
|
const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75);
|
||||||
const Color red400 = Color(0xFFEF5350);
|
const Color red400 = Color(0xFFEF5350);
|
||||||
const Color grey200 = Color(0xFFEEEEEE);
|
const Color grey200 = Color(0xFFEEEEEE);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ 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 {
|
AssetPlaybackStyle get playbackStyle {
|
||||||
if (isVideo) return AssetPlaybackStyle.video;
|
if (isVideo) return AssetPlaybackStyle.video;
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ class NetworkRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls) async {
|
static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls, {String? token}) async {
|
||||||
await networkApi.setRequestHeaders(headers, serverUrls);
|
await networkApi.setRequestHeaders(headers, serverUrls, token);
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
await init();
|
await init();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,11 +148,13 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1),
|
Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Flexible(
|
||||||
|
child: Text(
|
||||||
context.t.backup_error_sync_failed,
|
context.t.backup_error_sync_failed,
|
||||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
|
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -344,6 +346,7 @@ class _RemainderCard extends ConsumerWidget {
|
|||||||
remainderCount.toString(),
|
remainderCount.toString(),
|
||||||
style: context.textTheme.titleLarge?.copyWith(
|
style: context.textTheme.titleLarge?.copyWith(
|
||||||
color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255),
|
color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255),
|
||||||
|
fontFeatures: [const FontFeature.tabularFigures()],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (syncStatus.isRemoteSyncing)
|
if (syncStatus.isRemoteSyncing)
|
||||||
@@ -483,6 +486,7 @@ class _PreparingStatusState extends ConsumerState {
|
|||||||
style: context.textTheme.titleMedium?.copyWith(
|
style: context.textTheme.titleMedium?.copyWith(
|
||||||
color: context.colorScheme.primary,
|
color: context.colorScheme.primary,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFeatures: [const FontFeature.tabularFigures()],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -507,6 +511,7 @@ class _PreparingStatusState extends ConsumerState {
|
|||||||
style: context.textTheme.titleMedium?.copyWith(
|
style: context.textTheme.titleMedium?.copyWith(
|
||||||
color: context.primaryColor,
|
color: context.primaryColor,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFeatures: [const FontFeature.tabularFigures()],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Generated
+2
-2
@@ -281,7 +281,7 @@ class NetworkApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setRequestHeaders(Map<String, String> headers, List<String> serverUrls) async {
|
Future<void> setRequestHeaders(Map<String, String> headers, List<String> serverUrls, String? token) async {
|
||||||
final String pigeonVar_channelName =
|
final String pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix';
|
||||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
@@ -289,7 +289,7 @@ class NetworkApi {
|
|||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[headers, serverUrls]);
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[headers, serverUrls, token]);
|
||||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
if (pigeonVar_replyList == null) {
|
if (pigeonVar_replyList == null) {
|
||||||
throw _createConnectionError(pigeonVar_channelName);
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
|||||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||||
@@ -248,11 +247,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
|
|
||||||
if (scaleState != PhotoViewScaleState.initial) {
|
if (scaleState != PhotoViewScaleState.initial) {
|
||||||
if (_dragStart == null) _viewer.setControls(false);
|
if (_dragStart == null) _viewer.setControls(false);
|
||||||
|
|
||||||
final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
|
|
||||||
if (heroTag != null) {
|
|
||||||
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,18 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black.withAlpha(125),
|
decoration: const BoxDecoration(
|
||||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.bottomCenter,
|
||||||
|
end: Alignment.topCenter,
|
||||||
|
colors: [Colors.black45, Colors.black12, Colors.transparent],
|
||||||
|
stops: [0.0, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -73,6 +83,8 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||||
@@ -186,11 +185,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
|||||||
final source = await _videoSource;
|
final source = await _videoSource;
|
||||||
if (source == null || !mounted) return;
|
if (source == null || !mounted) return;
|
||||||
|
|
||||||
unawaited(
|
await _notifier.load(source);
|
||||||
nc.loadVideoSource(source).catchError((error) {
|
|
||||||
_log.severe('Error loading video source: $error');
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||||
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
|
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
|
||||||
await _notifier.setVolume(1);
|
await _notifier.setVolume(1);
|
||||||
@@ -213,21 +208,28 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Prevent the provider from being disposed whilst the widget is alive.
|
|
||||||
ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {});
|
|
||||||
|
|
||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status));
|
||||||
|
|
||||||
return Stack(
|
return IgnorePointer(
|
||||||
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Center(child: widget.image),
|
Center(child: widget.image),
|
||||||
if (!isCasting)
|
if (!isCasting) ...[
|
||||||
Visibility.maintain(
|
Visibility.maintain(
|
||||||
visible: _isVideoReady,
|
visible: _isVideoReady,
|
||||||
child: NativeVideoPlayerView(onViewReady: _initController),
|
child: NativeVideoPlayerView(onViewReady: _initController),
|
||||||
),
|
),
|
||||||
if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
|
Center(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: status == VideoPlaybackStatus.buffering ? 1.0 : 0.0,
|
||||||
|
duration: const Duration(milliseconds: 400),
|
||||||
|
child: const CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
|
||||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
|
||||||
|
|
||||||
class VideoViewerControls extends HookConsumerWidget {
|
|
||||||
final BaseAsset asset;
|
|
||||||
final Duration hideTimerDuration;
|
|
||||||
|
|
||||||
const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final videoPlayerName = asset.heroTag;
|
|
||||||
final assetIsVideo = asset.isVideo;
|
|
||||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails));
|
|
||||||
final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status));
|
|
||||||
|
|
||||||
final cast = ref.watch(castProvider);
|
|
||||||
|
|
||||||
// A timer to hide the controls
|
|
||||||
final hideTimer = useTimer(hideTimerDuration, () {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
|
||||||
|
|
||||||
// Do not hide on paused
|
|
||||||
if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) {
|
|
||||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
|
|
||||||
|
|
||||||
/// Shows the controls and starts the timer to hide them
|
|
||||||
void showControlsAndStartHideTimer() {
|
|
||||||
hideTimer.reset();
|
|
||||||
ref.read(assetViewerProvider.notifier).setControls(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// When playback starts, reset the hide timer
|
|
||||||
ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) {
|
|
||||||
if (next == VideoPlaybackStatus.playing) {
|
|
||||||
hideTimer.reset();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Toggles between playing and pausing depending on the state of the video
|
|
||||||
void togglePlay() {
|
|
||||||
showControlsAndStartHideTimer();
|
|
||||||
|
|
||||||
if (cast.isCasting) {
|
|
||||||
switch (cast.castState) {
|
|
||||||
case CastState.playing:
|
|
||||||
ref.read(castProvider.notifier).pause();
|
|
||||||
case CastState.paused:
|
|
||||||
ref.read(castProvider.notifier).play();
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
|
|
||||||
switch (status) {
|
|
||||||
case VideoPlaybackStatus.playing:
|
|
||||||
notifier.pause();
|
|
||||||
case VideoPlaybackStatus.completed:
|
|
||||||
notifier.restart();
|
|
||||||
default:
|
|
||||||
notifier.play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void toggleControlsVisibility() {
|
|
||||||
if (showBuffering) return;
|
|
||||||
|
|
||||||
if (showControls) {
|
|
||||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
|
||||||
} else {
|
|
||||||
showControlsAndStartHideTimer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
behavior: HitTestBehavior.translucent,
|
|
||||||
onTap: toggleControlsVisibility,
|
|
||||||
child: IgnorePointer(
|
|
||||||
ignoring: !showControls,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
if (showBuffering)
|
|
||||||
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
|
|
||||||
else
|
|
||||||
CenterPlayButton(
|
|
||||||
backgroundColor: Colors.black54,
|
|
||||||
iconColor: Colors.white,
|
|
||||||
isFinished: status == VideoPlaybackStatus.completed,
|
|
||||||
isPlaying:
|
|
||||||
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
|
|
||||||
show: assetIsVideo && showControls,
|
|
||||||
onPressed: togglePlay,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -75,8 +75,19 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: opacity,
|
opacity: opacity,
|
||||||
duration: Durations.short2,
|
duration: Durations.short2,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: showingDetails
|
||||||
|
? null
|
||||||
|
: const LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.black45, Colors.black12, Colors.transparent],
|
||||||
|
stops: [0.0, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
child: AppBar(
|
child: AppBar(
|
||||||
backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5),
|
backgroundColor: Colors.transparent,
|
||||||
leading: const _AppBarBackButton(),
|
leading: const _AppBarBackButton(),
|
||||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||||
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||||
@@ -88,6 +99,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
: actions,
|
: actions,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,17 +113,14 @@ class _AppBarBackButton extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||||
final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black;
|
|
||||||
final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white;
|
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 12.0),
|
padding: const EdgeInsets.only(left: 12.0),
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent,
|
||||||
shape: const CircleBorder(),
|
shape: const CircleBorder(),
|
||||||
iconSize: 22,
|
iconSize: 22,
|
||||||
iconColor: foregroundColor,
|
iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
elevation: showingDetails ? 4 : 0,
|
elevation: showingDetails ? 4 : 0,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show InformationCollector;
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
|
||||||
|
/// A [MultiFrameImageStreamCompleter] with support for listener tracking
|
||||||
|
/// which makes resource cleanup possible when no longer needed.
|
||||||
|
/// Codec is disposed through the MultiFrameImageStreamCompleter's internals onDispose method
|
||||||
|
class AnimatedImageStreamCompleter extends MultiFrameImageStreamCompleter {
|
||||||
|
void Function()? _onLastListenerRemoved;
|
||||||
|
int _listenerCount = 0;
|
||||||
|
// True once any image or the codec has been provided.
|
||||||
|
// Until then the image cache holds one listener, so "last real listener gone"
|
||||||
|
// is _listenerCount == 1, not 0.
|
||||||
|
bool didProvideImage = false;
|
||||||
|
|
||||||
|
AnimatedImageStreamCompleter._({
|
||||||
|
required super.codec,
|
||||||
|
required super.scale,
|
||||||
|
super.informationCollector,
|
||||||
|
void Function()? onLastListenerRemoved,
|
||||||
|
}) : _onLastListenerRemoved = onLastListenerRemoved;
|
||||||
|
|
||||||
|
factory AnimatedImageStreamCompleter({
|
||||||
|
required Stream<Object> stream,
|
||||||
|
required double scale,
|
||||||
|
ImageInfo? initialImage,
|
||||||
|
InformationCollector? informationCollector,
|
||||||
|
void Function()? onLastListenerRemoved,
|
||||||
|
}) {
|
||||||
|
final codecCompleter = Completer<ui.Codec>();
|
||||||
|
final self = AnimatedImageStreamCompleter._(
|
||||||
|
codec: codecCompleter.future,
|
||||||
|
scale: scale,
|
||||||
|
informationCollector: informationCollector,
|
||||||
|
onLastListenerRemoved: onLastListenerRemoved,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (initialImage != null) {
|
||||||
|
self.didProvideImage = true;
|
||||||
|
self.setImage(initialImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.listen(
|
||||||
|
(item) {
|
||||||
|
if (item is ImageInfo) {
|
||||||
|
self.didProvideImage = true;
|
||||||
|
self.setImage(item);
|
||||||
|
} else if (item is ui.Codec) {
|
||||||
|
if (!codecCompleter.isCompleted) {
|
||||||
|
self.didProvideImage = true;
|
||||||
|
codecCompleter.complete(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (Object error, StackTrace stack) {
|
||||||
|
if (!codecCompleter.isCompleted) {
|
||||||
|
codecCompleter.completeError(error, stack);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
// also complete if we are done but no error occurred, and we didn't call complete yet
|
||||||
|
// could happen on cancellation
|
||||||
|
if (!codecCompleter.isCompleted) {
|
||||||
|
codecCompleter.completeError(StateError('Stream closed without providing a codec'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void addListener(ImageStreamListener listener) {
|
||||||
|
super.addListener(listener);
|
||||||
|
_listenerCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void removeListener(ImageStreamListener listener) {
|
||||||
|
super.removeListener(listener);
|
||||||
|
_listenerCount--;
|
||||||
|
|
||||||
|
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
|
||||||
|
final bool noListenersAfterCodec = _listenerCount == 0 && didProvideImage;
|
||||||
|
|
||||||
|
if (onlyCacheListenerLeft || noListenersAfterCodec) {
|
||||||
|
final onLastListenerRemoved = _onLastListenerRemoved;
|
||||||
|
if (onLastListenerRemoved != null) {
|
||||||
|
_onLastListenerRemoved = null;
|
||||||
|
onLastListenerRemoved();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,7 +140,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
|
|||||||
final ImageProvider provider;
|
final ImageProvider provider;
|
||||||
if (_shouldUseLocalAsset(asset)) {
|
if (_shouldUseLocalAsset(asset)) {
|
||||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||||
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type);
|
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
|
||||||
} else {
|
} else {
|
||||||
final String assetId;
|
final String assetId;
|
||||||
final String thumbhash;
|
final String thumbhash;
|
||||||
@@ -153,7 +153,12 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
|
|||||||
} else {
|
} else {
|
||||||
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
|
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
|
||||||
}
|
}
|
||||||
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type);
|
provider = RemoteFullImageProvider(
|
||||||
|
assetId: assetId,
|
||||||
|
thumbhash: thumbhash,
|
||||||
|
assetType: asset.type,
|
||||||
|
isAnimated: asset.isAnimatedImage,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return provider;
|
return provider;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||||
@@ -58,8 +57,9 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||||||
final String id;
|
final String id;
|
||||||
final Size size;
|
final Size size;
|
||||||
final AssetType assetType;
|
final AssetType assetType;
|
||||||
|
final bool isAnimated;
|
||||||
|
|
||||||
LocalFullImageProvider({required this.id, required this.assetType, required this.size});
|
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
@@ -68,6 +68,21 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
||||||
|
if (key.isAnimated) {
|
||||||
|
return AnimatedImageStreamCompleter(
|
||||||
|
stream: _animatedCodec(key, decode),
|
||||||
|
scale: 1.0,
|
||||||
|
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||||
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
|
DiagnosticsProperty<String>('Id', key.id),
|
||||||
|
DiagnosticsProperty<Size>('Size', key.size),
|
||||||
|
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
|
||||||
|
],
|
||||||
|
onLastListenerRemoved: cancel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return OneFramePlaceholderImageStreamCompleter(
|
return OneFramePlaceholderImageStreamCompleter(
|
||||||
_codec(key, decode),
|
_codec(key, decode),
|
||||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||||
@@ -75,6 +90,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
DiagnosticsProperty<String>('Id', key.id),
|
DiagnosticsProperty<String>('Id', key.id),
|
||||||
DiagnosticsProperty<Size>('Size', key.size),
|
DiagnosticsProperty<Size>('Size', key.size),
|
||||||
|
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
|
||||||
],
|
],
|
||||||
onLastListenerRemoved: cancel,
|
onLastListenerRemoved: cancel,
|
||||||
);
|
);
|
||||||
@@ -110,15 +126,45 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||||||
yield* loadRequest(request, decode);
|
yield* loadRequest(request, decode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Stream<Object> _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||||
|
yield* initialImageStream();
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
PaintingBinding.instance.imageCache.evict(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||||
|
final previewRequest = request = LocalImageRequest(
|
||||||
|
localId: key.id,
|
||||||
|
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||||
|
assetType: key.assetType,
|
||||||
|
);
|
||||||
|
yield* loadRequest(previewRequest, decode);
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
PaintingBinding.instance.imageCache.evict(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// always try original for animated, since previews don't support animation
|
||||||
|
final originalRequest = request = LocalImageRequest(localId: key.id, size: Size.zero, assetType: key.assetType);
|
||||||
|
final codec = await loadCodecRequest(originalRequest);
|
||||||
|
if (codec == null) {
|
||||||
|
throw StateError('Failed to load animated codec for local asset ${key.id}');
|
||||||
|
}
|
||||||
|
yield codec;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
if (other is LocalFullImageProvider) {
|
if (other is LocalFullImageProvider) {
|
||||||
return id == other.id && size == other.size;
|
return id == other.id && size == other.size && isAnimated == other.isAnimated;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => id.hashCode ^ size.hashCode;
|
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||||
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
@@ -58,8 +59,14 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
|||||||
final String assetId;
|
final String assetId;
|
||||||
final String thumbhash;
|
final String thumbhash;
|
||||||
final AssetType assetType;
|
final AssetType assetType;
|
||||||
|
final bool isAnimated;
|
||||||
|
|
||||||
RemoteFullImageProvider({required this.assetId, required this.thumbhash, required this.assetType});
|
RemoteFullImageProvider({
|
||||||
|
required this.assetId,
|
||||||
|
required this.thumbhash,
|
||||||
|
required this.assetType,
|
||||||
|
required this.isAnimated,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
@@ -68,12 +75,27 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
|
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
|
||||||
|
if (key.isAnimated) {
|
||||||
|
return AnimatedImageStreamCompleter(
|
||||||
|
stream: _animatedCodec(key, decode),
|
||||||
|
scale: 1.0,
|
||||||
|
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
|
||||||
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
|
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||||
|
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
|
||||||
|
],
|
||||||
|
onLastListenerRemoved: cancel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return OneFramePlaceholderImageStreamCompleter(
|
return OneFramePlaceholderImageStreamCompleter(
|
||||||
_codec(key, decode),
|
_codec(key, decode),
|
||||||
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
|
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
|
||||||
informationCollector: () => <DiagnosticsNode>[
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||||
|
DiagnosticsProperty<bool>('isAnimated', key.isAnimated),
|
||||||
],
|
],
|
||||||
onLastListenerRemoved: cancel,
|
onLastListenerRemoved: cancel,
|
||||||
);
|
);
|
||||||
@@ -106,16 +128,43 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
|||||||
yield* loadRequest(originalRequest, decode);
|
yield* loadRequest(originalRequest, decode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Stream<Object> _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||||
|
yield* initialImageStream();
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
PaintingBinding.instance.imageCache.evict(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final previewRequest = request = RemoteImageRequest(
|
||||||
|
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
|
||||||
|
);
|
||||||
|
yield* loadRequest(previewRequest, decode, evictOnError: false);
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
PaintingBinding.instance.imageCache.evict(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// always try original for animated, since previews don't support animation
|
||||||
|
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
|
||||||
|
final codec = await loadCodecRequest(originalRequest);
|
||||||
|
if (codec == null) {
|
||||||
|
throw StateError('Failed to load animated codec for asset ${key.assetId}');
|
||||||
|
}
|
||||||
|
yield codec;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
if (other is RemoteFullImageProvider) {
|
if (other is RemoteFullImageProvider) {
|
||||||
return assetId == other.assetId && thumbhash == other.thumbhash;
|
return assetId == other.assetId && thumbhash == other.thumbhash && isAnimated == other.isAnimated;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
|
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ isAnimated.hashCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,6 +305,8 @@ class _AssetTypeIcons extends StatelessWidget {
|
|||||||
padding: EdgeInsets.only(right: 10.0, top: 6.0),
|
padding: EdgeInsets.only(right: 10.0, top: 6.0),
|
||||||
child: _TileOverlayIcon(Icons.motion_photos_on_rounded),
|
child: _TileOverlayIcon(Icons.motion_photos_on_rounded),
|
||||||
),
|
),
|
||||||
|
if (asset.isAnimatedImage)
|
||||||
|
const Padding(padding: EdgeInsets.only(right: 10.0, top: 6.0), child: _TileOverlayIcon(Icons.gif_rounded)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
||||||
if (showing) {
|
|
||||||
final heroTag = state.currentAsset?.heroTag;
|
final heroTag = state.currentAsset?.heroTag;
|
||||||
if (heroTag != null) {
|
if (heroTag != null) {
|
||||||
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
final notifier = ref.read(videoPlayerProvider(heroTag).notifier);
|
||||||
}
|
showing ? notifier.hold() : notifier.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,10 +44,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
|||||||
NativeVideoPlayerController? _controller;
|
NativeVideoPlayerController? _controller;
|
||||||
Timer? _bufferingTimer;
|
Timer? _bufferingTimer;
|
||||||
Timer? _seekTimer;
|
Timer? _seekTimer;
|
||||||
|
VideoPlaybackStatus? _holdStatus;
|
||||||
void attachController(NativeVideoPlayerController controller) {
|
|
||||||
_controller = controller;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -59,6 +56,19 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void attachController(NativeVideoPlayerController controller) {
|
||||||
|
_controller = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> load(VideoSource source) async {
|
||||||
|
_startBufferingTimer();
|
||||||
|
try {
|
||||||
|
await _controller?.loadVideoSource(source);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Error loading video source: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> pause() async {
|
Future<void> pause() async {
|
||||||
if (_controller == null) return;
|
if (_controller == null) return;
|
||||||
|
|
||||||
@@ -94,16 +104,50 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void seekTo(Duration position) {
|
void seekTo(Duration position) {
|
||||||
if (_controller == null) return;
|
if (_controller == null || state.position == position) return;
|
||||||
|
|
||||||
state = state.copyWith(position: position);
|
state = state.copyWith(position: position);
|
||||||
|
|
||||||
_seekTimer?.cancel();
|
if (_seekTimer?.isActive ?? false) return;
|
||||||
_seekTimer = Timer(const Duration(milliseconds: 100), () {
|
|
||||||
_controller?.seekTo(position.inMilliseconds);
|
_seekTimer = Timer(const Duration(milliseconds: 150), () {
|
||||||
|
_controller?.seekTo(state.position.inMilliseconds);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void toggle() {
|
||||||
|
_holdStatus = null;
|
||||||
|
|
||||||
|
switch (state.status) {
|
||||||
|
case VideoPlaybackStatus.paused:
|
||||||
|
play();
|
||||||
|
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
|
||||||
|
pause();
|
||||||
|
case VideoPlaybackStatus.completed:
|
||||||
|
restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pauses playback and preserves the current status for later restoration.
|
||||||
|
void hold() {
|
||||||
|
if (_holdStatus != null) return;
|
||||||
|
|
||||||
|
_holdStatus = state.status;
|
||||||
|
pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores playback to the status before [hold] was called.
|
||||||
|
void release() {
|
||||||
|
final status = _holdStatus;
|
||||||
|
_holdStatus = null;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
|
||||||
|
play();
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> restart() async {
|
Future<void> restart() async {
|
||||||
seekTo(Duration.zero);
|
seekTo(Duration.zero);
|
||||||
await play();
|
await play();
|
||||||
@@ -149,13 +193,12 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
|||||||
final position = Duration(milliseconds: playbackInfo.position);
|
final position = Duration(milliseconds: playbackInfo.position);
|
||||||
if (state.position == position) return;
|
if (state.position == position) return;
|
||||||
|
|
||||||
if (state.status == VideoPlaybackStatus.buffering) {
|
if (state.status == VideoPlaybackStatus.playing) _startBufferingTimer();
|
||||||
state = state.copyWith(position: position, status: VideoPlaybackStatus.playing);
|
|
||||||
} else {
|
|
||||||
state = state.copyWith(position: position);
|
|
||||||
}
|
|
||||||
|
|
||||||
_startBufferingTimer();
|
state = state.copyWith(
|
||||||
|
position: position,
|
||||||
|
status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onNativeStatusChanged() {
|
void onNativeStatusChanged() {
|
||||||
@@ -173,9 +216,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
|||||||
onNativePlaybackEnded();
|
onNativePlaybackEnded();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.status != newStatus) {
|
if (state.status != newStatus) state = state.copyWith(status: newStatus);
|
||||||
state = state.copyWith(status: newStatus);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onNativePlaybackEnded() {
|
void onNativePlaybackEnded() {
|
||||||
@@ -186,7 +227,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
|||||||
void _startBufferingTimer() {
|
void _startBufferingTimer() {
|
||||||
_bufferingTimer?.cancel();
|
_bufferingTimer?.cancel();
|
||||||
_bufferingTimer = Timer(const Duration(seconds: 3), () {
|
_bufferingTimer = Timer(const Duration(seconds: 3), () {
|
||||||
if (mounted && state.status == VideoPlaybackStatus.playing) {
|
if (mounted && state.status != VideoPlaybackStatus.completed) {
|
||||||
state = state.copyWith(status: VideoPlaybackStatus.buffering);
|
state = state.copyWith(status: VideoPlaybackStatus.buffering);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> saveAuthInfo({required String accessToken}) async {
|
Future<bool> saveAuthInfo({required String accessToken}) async {
|
||||||
await _apiService.setAccessToken(accessToken);
|
await Store.put(StoreKey.accessToken, accessToken);
|
||||||
await _apiService.updateHeaders();
|
await _apiService.updateHeaders();
|
||||||
|
|
||||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
@@ -145,7 +145,6 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
user = serverUser;
|
user = serverUser;
|
||||||
await Store.put(StoreKey.deviceId, deviceId);
|
await Store.put(StoreKey.deviceId, deviceId);
|
||||||
await Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
|
await Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
|
||||||
await Store.put(StoreKey.accessToken, accessToken);
|
|
||||||
}
|
}
|
||||||
} on ApiException catch (error, stackTrace) {
|
} on ApiException catch (error, stackTrace) {
|
||||||
if (error.code == 401) {
|
if (error.code == 401) {
|
||||||
|
|||||||
@@ -91,6 +91,16 @@ class CastNotifier extends StateNotifier<CastManagerState> {
|
|||||||
return discovered;
|
return discovered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void toggle() {
|
||||||
|
switch (state.castState) {
|
||||||
|
case CastState.playing:
|
||||||
|
pause();
|
||||||
|
case CastState.paused:
|
||||||
|
play();
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void play() {
|
void play() {
|
||||||
_gCastService.play();
|
_gCastService.play();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,6 @@ class AuthRepository extends DatabaseRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
String getAccessToken() {
|
|
||||||
return Store.get(StoreKey.accessToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool getEndpointSwitchingFeature() {
|
bool getEndpointSwitchingFeature() {
|
||||||
return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false;
|
return Store.tryGet(StoreKey.autoEndpointSwitching) ?? false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import 'package:immich_mobile/utils/url_helper.dart';
|
|||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
class ApiService implements Authentication {
|
class ApiService {
|
||||||
late ApiClient _apiClient;
|
late ApiClient _apiClient;
|
||||||
|
|
||||||
late UsersApi usersApi;
|
late UsersApi usersApi;
|
||||||
@@ -45,7 +45,6 @@ class ApiService implements Authentication {
|
|||||||
setEndpoint(endpoint);
|
setEndpoint(endpoint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
String? _accessToken;
|
|
||||||
final _log = Logger("ApiService");
|
final _log = Logger("ApiService");
|
||||||
|
|
||||||
Future<void> updateHeaders() async {
|
Future<void> updateHeaders() async {
|
||||||
@@ -54,11 +53,8 @@ class ApiService implements Authentication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setEndpoint(String endpoint) {
|
setEndpoint(String endpoint) {
|
||||||
_apiClient = ApiClient(basePath: endpoint, authentication: this);
|
_apiClient = ApiClient(basePath: endpoint);
|
||||||
_apiClient.client = NetworkRepository.client;
|
_apiClient.client = NetworkRepository.client;
|
||||||
if (_accessToken != null) {
|
|
||||||
setAccessToken(_accessToken!);
|
|
||||||
}
|
|
||||||
usersApi = UsersApi(_apiClient);
|
usersApi = UsersApi(_apiClient);
|
||||||
authenticationApi = AuthenticationApi(_apiClient);
|
authenticationApi = AuthenticationApi(_apiClient);
|
||||||
oAuthApi = AuthenticationApi(_apiClient);
|
oAuthApi = AuthenticationApi(_apiClient);
|
||||||
@@ -157,11 +153,6 @@ class ApiService implements Authentication {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setAccessToken(String accessToken) async {
|
|
||||||
_accessToken = accessToken;
|
|
||||||
await Store.put(StoreKey.accessToken, accessToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setDeviceInfoHeader() async {
|
Future<void> setDeviceInfoHeader() async {
|
||||||
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||||
|
|
||||||
@@ -205,28 +196,12 @@ class ApiService implements Authentication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Map<String, String> getRequestHeaders() {
|
static Map<String, String> getRequestHeaders() {
|
||||||
var accessToken = Store.get(StoreKey.accessToken, "");
|
|
||||||
var customHeadersStr = Store.get(StoreKey.customHeaders, "");
|
var customHeadersStr = Store.get(StoreKey.customHeaders, "");
|
||||||
var header = <String, String>{};
|
|
||||||
if (accessToken.isNotEmpty) {
|
|
||||||
header['x-immich-user-token'] = accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customHeadersStr.isEmpty) {
|
if (customHeadersStr.isEmpty) {
|
||||||
return header;
|
return const {};
|
||||||
}
|
}
|
||||||
|
|
||||||
var customHeaders = jsonDecode(customHeadersStr) as Map;
|
return (jsonDecode(customHeadersStr) as Map).cast<String, String>();
|
||||||
customHeaders.forEach((key, value) {
|
|
||||||
header[key] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return header;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> applyToParams(List<QueryParam> queryParams, Map<String, String> headerParams) {
|
|
||||||
return Future.value();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiClient get apiClient => _apiClient;
|
ApiClient get apiClient => _apiClient;
|
||||||
|
|||||||
@@ -340,7 +340,6 @@ class BackgroundService {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken));
|
|
||||||
await ref.read(authServiceProvider).setOpenApiServiceEndpoint();
|
await ref.read(authServiceProvider).setOpenApiServiceEndpoint();
|
||||||
dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}");
|
dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}");
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ class BackupVerificationService {
|
|||||||
final lower = compute(_computeSaveToDelete, (
|
final lower = compute(_computeSaveToDelete, (
|
||||||
deleteCandidates: deleteCandidates.slice(0, half),
|
deleteCandidates: deleteCandidates.slice(0, half),
|
||||||
originals: originals.slice(0, half),
|
originals: originals.slice(0, half),
|
||||||
auth: Store.get(StoreKey.accessToken),
|
|
||||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||||
rootIsolateToken: isolateToken,
|
rootIsolateToken: isolateToken,
|
||||||
fileMediaRepository: _fileMediaRepository,
|
fileMediaRepository: _fileMediaRepository,
|
||||||
@@ -82,7 +81,6 @@ class BackupVerificationService {
|
|||||||
final upper = compute(_computeSaveToDelete, (
|
final upper = compute(_computeSaveToDelete, (
|
||||||
deleteCandidates: deleteCandidates.slice(half),
|
deleteCandidates: deleteCandidates.slice(half),
|
||||||
originals: originals.slice(half),
|
originals: originals.slice(half),
|
||||||
auth: Store.get(StoreKey.accessToken),
|
|
||||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||||
rootIsolateToken: isolateToken,
|
rootIsolateToken: isolateToken,
|
||||||
fileMediaRepository: _fileMediaRepository,
|
fileMediaRepository: _fileMediaRepository,
|
||||||
@@ -92,7 +90,6 @@ class BackupVerificationService {
|
|||||||
toDelete = await compute(_computeSaveToDelete, (
|
toDelete = await compute(_computeSaveToDelete, (
|
||||||
deleteCandidates: deleteCandidates,
|
deleteCandidates: deleteCandidates,
|
||||||
originals: originals,
|
originals: originals,
|
||||||
auth: Store.get(StoreKey.accessToken),
|
|
||||||
endpoint: Store.get(StoreKey.serverEndpoint),
|
endpoint: Store.get(StoreKey.serverEndpoint),
|
||||||
rootIsolateToken: isolateToken,
|
rootIsolateToken: isolateToken,
|
||||||
fileMediaRepository: _fileMediaRepository,
|
fileMediaRepository: _fileMediaRepository,
|
||||||
@@ -105,7 +102,6 @@ class BackupVerificationService {
|
|||||||
({
|
({
|
||||||
List<Asset> deleteCandidates,
|
List<Asset> deleteCandidates,
|
||||||
List<Asset> originals,
|
List<Asset> originals,
|
||||||
String auth,
|
|
||||||
String endpoint,
|
String endpoint,
|
||||||
RootIsolateToken rootIsolateToken,
|
RootIsolateToken rootIsolateToken,
|
||||||
FileMediaRepository fileMediaRepository,
|
FileMediaRepository fileMediaRepository,
|
||||||
@@ -120,7 +116,6 @@ class BackupVerificationService {
|
|||||||
await tuple.fileMediaRepository.enableBackgroundAccess();
|
await tuple.fileMediaRepository.enableBackgroundAccess();
|
||||||
final ApiService apiService = ApiService();
|
final ApiService apiService = ApiService();
|
||||||
apiService.setEndpoint(tuple.endpoint);
|
apiService.setEndpoint(tuple.endpoint);
|
||||||
await apiService.setAccessToken(tuple.auth);
|
|
||||||
for (int i = 0; i < tuple.deleteCandidates.length; i++) {
|
for (int i = 0; i < tuple.deleteCandidates.length; i++) {
|
||||||
if (await _compareAssets(tuple.deleteCandidates[i], tuple.originals[i], apiService)) {
|
if (await _compareAssets(tuple.deleteCandidates[i], tuple.originals[i], apiService)) {
|
||||||
result.add(tuple.deleteCandidates[i]);
|
result.add(tuple.deleteCandidates[i]);
|
||||||
|
|||||||
@@ -62,8 +62,6 @@ ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale
|
|||||||
),
|
),
|
||||||
chipTheme: const ChipThemeData(side: BorderSide.none),
|
chipTheme: const ChipThemeData(side: BorderSide.none),
|
||||||
sliderTheme: const SliderThemeData(
|
sliderTheme: const SliderThemeData(
|
||||||
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
|
|
||||||
trackHeight: 2.0,
|
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
year2023: false,
|
year2023: false,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
|
||||||
import 'package:immich_mobile/platform/network_api.g.dart';
|
import 'package:immich_mobile/platform/network_api.g.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
@@ -35,7 +37,7 @@ import 'package:isar/isar.dart';
|
|||||||
// ignore: import_rule_photo_manager
|
// ignore: import_rule_photo_manager
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
const int targetVersion = 23;
|
const int targetVersion = 25;
|
||||||
|
|
||||||
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||||
@@ -105,6 +107,20 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
|||||||
await _populateLocalAssetPlaybackStyle(drift);
|
await _populateLocalAssetPlaybackStyle(drift);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version < 24 && Store.isBetaTimelineEnabled) {
|
||||||
|
await _applyLocalAssetOrientation(drift);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version < 25) {
|
||||||
|
final accessToken = Store.tryGet(StoreKey.accessToken);
|
||||||
|
if (accessToken != null && accessToken.isNotEmpty) {
|
||||||
|
final serverUrls = ApiService.getServerUrls();
|
||||||
|
if (serverUrls.isNotEmpty) {
|
||||||
|
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), serverUrls, token: accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (version < 22 && !Store.isBetaTimelineEnabled) {
|
if (version < 22 && !Store.isBetaTimelineEnabled) {
|
||||||
await Store.put(StoreKey.needBetaMigration, true);
|
await Store.put(StoreKey.needBetaMigration, true);
|
||||||
}
|
}
|
||||||
@@ -416,6 +432,7 @@ Future<void> _populateLocalAssetPlaybackStyle(Drift db) async {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Platform.isAndroid) {
|
||||||
final trashedAssetMap = await nativeApi.getTrashedAssets();
|
final trashedAssetMap = await nativeApi.getTrashedAssets();
|
||||||
for (final entry in trashedAssetMap.cast<String, List<Object?>>().entries) {
|
for (final entry in trashedAssetMap.cast<String, List<Object?>>().entries) {
|
||||||
final assets = entry.value.cast<PlatformAsset>();
|
final assets = entry.value.cast<PlatformAsset>();
|
||||||
@@ -429,13 +446,27 @@ Future<void> _populateLocalAssetPlaybackStyle(Drift db) async {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local and trashed assets");
|
dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local and trashed assets");
|
||||||
|
} else {
|
||||||
|
dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local assets");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dPrint(() => "[MIGRATION] Error while populating playbackStyle: $error");
|
dPrint(() => "[MIGRATION] Error while populating playbackStyle: $error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _applyLocalAssetOrientation(Drift db) {
|
||||||
|
final query = db.localAssetEntity.update()
|
||||||
|
..where((filter) => (filter.orientation.equals(90) | (filter.orientation.equals(270))));
|
||||||
|
return query.write(
|
||||||
|
LocalAssetEntityCompanion.custom(
|
||||||
|
width: db.localAssetEntity.height,
|
||||||
|
height: db.localAssetEntity.width,
|
||||||
|
orientation: const Variable(0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
|
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
|
||||||
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
|
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
|
||||||
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
|
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// A widget that animates implicitly between a play and a pause icon.
|
/// A widget that animates implicitly between a play and a pause icon.
|
||||||
class AnimatedPlayPause extends StatefulWidget {
|
class AnimatedPlayPause extends StatefulWidget {
|
||||||
const AnimatedPlayPause({super.key, required this.playing, this.size, this.color});
|
const AnimatedPlayPause({super.key, required this.playing, this.size, this.color, this.shadows});
|
||||||
|
|
||||||
final double? size;
|
final double? size;
|
||||||
final bool playing;
|
final bool playing;
|
||||||
final Color? color;
|
final Color? color;
|
||||||
|
final List<Shadow>? shadows;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StatefulWidget> createState() => AnimatedPlayPauseState();
|
State<StatefulWidget> createState() => AnimatedPlayPauseState();
|
||||||
@@ -39,12 +42,32 @@ class AnimatedPlayPauseState extends State<AnimatedPlayPause> with SingleTickerP
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Center(
|
final icon = AnimatedIcon(
|
||||||
child: AnimatedIcon(
|
|
||||||
color: widget.color,
|
color: widget.color,
|
||||||
size: widget.size,
|
size: widget.size,
|
||||||
icon: AnimatedIcons.play_pause,
|
icon: AnimatedIcons.play_pause,
|
||||||
progress: animationController,
|
progress: animationController,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
for (final shadow in widget.shadows ?? const <Shadow>[])
|
||||||
|
Transform.translate(
|
||||||
|
offset: shadow.offset,
|
||||||
|
child: ImageFiltered(
|
||||||
|
imageFilter: ImageFilter.blur(sigmaX: shadow.blurRadius / 2, sigmaY: shadow.blurRadius / 2),
|
||||||
|
child: AnimatedIcon(
|
||||||
|
color: shadow.color,
|
||||||
|
size: widget.size,
|
||||||
|
icon: AnimatedIcons.play_pause,
|
||||||
|
progress: animationController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
|
||||||
|
|
||||||
class FormattedDuration extends StatelessWidget {
|
|
||||||
final Duration data;
|
|
||||||
const FormattedDuration(this.data, {super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SizedBox(
|
|
||||||
width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter
|
|
||||||
child: Text(
|
|
||||||
data.format(),
|
|
||||||
style: const TextStyle(fontSize: 14.0, color: Colors.white, fontWeight: FontWeight.w500),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,113 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
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/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/constants/colors.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
|
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||||
|
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart';
|
||||||
|
|
||||||
/// The video controls for the [videoPlayerProvider]
|
class VideoControls extends HookConsumerWidget {
|
||||||
class VideoControls extends ConsumerWidget {
|
|
||||||
final String videoPlayerName;
|
final String videoPlayerName;
|
||||||
|
|
||||||
|
static const List<Shadow> _controlShadows = [Shadow(color: Colors.black87, blurRadius: 6, offset: Offset(0, 1))];
|
||||||
|
|
||||||
const VideoControls({super.key, required this.videoPlayerName});
|
const VideoControls({super.key, required this.videoPlayerName});
|
||||||
|
|
||||||
|
void _toggle(WidgetRef ref, bool isCasting) {
|
||||||
|
if (isCasting) {
|
||||||
|
ref.read(castProvider.notifier).toggle();
|
||||||
|
} else {
|
||||||
|
ref.read(videoPlayerProvider(videoPlayerName).notifier).toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSeek(WidgetRef ref, bool isCasting, double value) {
|
||||||
|
final seekTo = Duration(microseconds: value.toInt());
|
||||||
|
|
||||||
|
if (isCasting) {
|
||||||
|
ref.read(castProvider.notifier).seekTo(seekTo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekTo);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isPortrait = context.orientation == Orientation.portrait;
|
final provider = videoPlayerProvider(videoPlayerName);
|
||||||
return isPortrait
|
final cast = ref.watch(castProvider);
|
||||||
? VideoPosition(videoPlayerName: videoPlayerName)
|
final isCasting = cast.isCasting;
|
||||||
: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 60.0),
|
final (position, duration) = isCasting
|
||||||
child: VideoPosition(videoPlayerName: videoPlayerName),
|
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
||||||
|
: ref.watch(provider.select((v) => (v.position, v.duration)));
|
||||||
|
|
||||||
|
final videoStatus = ref.watch(provider.select((v) => v.status));
|
||||||
|
final isPlaying = isCasting
|
||||||
|
? cast.castState == CastState.playing
|
||||||
|
: videoStatus == VideoPlaybackStatus.playing || videoStatus == VideoPlaybackStatus.buffering;
|
||||||
|
final isFinished = !isCasting && videoStatus == VideoPlaybackStatus.completed;
|
||||||
|
|
||||||
|
final hideTimer = useTimer(const Duration(seconds: 5), () {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (ref.read(provider).status == VideoPlaybackStatus.playing) {
|
||||||
|
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ref.listen(provider.select((v) => v.status), (_, __) => hideTimer.reset());
|
||||||
|
|
||||||
|
final notifier = ref.read(provider.notifier);
|
||||||
|
final isLoaded = duration != Duration.zero;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
spacing: 16,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
iconSize: 32,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
icon: isFinished
|
||||||
|
? const Icon(Icons.replay, color: Colors.white, size: 32, shadows: _controlShadows)
|
||||||
|
: AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying, shadows: _controlShadows),
|
||||||
|
onPressed: () => _toggle(ref, isCasting),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
"${position.format()} / ${duration.format()}",
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
|
shadows: _controlShadows,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
value: min(position.inMicroseconds.toDouble(), duration.inMicroseconds.toDouble()),
|
||||||
|
min: 0,
|
||||||
|
max: max(duration.inMicroseconds.toDouble(), 1),
|
||||||
|
thumbColor: Colors.white,
|
||||||
|
activeColor: Colors.white,
|
||||||
|
inactiveColor: whiteOpacity75,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onChangeStart: (_) => notifier.hold(),
|
||||||
|
onChangeEnd: (_) => notifier.release(),
|
||||||
|
onChanged: isLoaded ? (value) => _onSeek(ref, isCasting, value) : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/colors.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
|
|
||||||
|
|
||||||
class VideoPosition extends HookConsumerWidget {
|
|
||||||
final String videoPlayerName;
|
|
||||||
|
|
||||||
const VideoPosition({super.key, required this.videoPlayerName});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final isCasting = ref.watch(castProvider).isCasting;
|
|
||||||
|
|
||||||
final (position, duration) = isCasting
|
|
||||||
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
|
||||||
: ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration)));
|
|
||||||
|
|
||||||
final wasPlaying = useRef<bool>(true);
|
|
||||||
return duration == Duration.zero
|
|
||||||
? const _VideoPositionPlaceholder()
|
|
||||||
: Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
// align with slider's inherent padding
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [FormattedDuration(position), FormattedDuration(duration)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Slider(
|
|
||||||
value: min(position.inMicroseconds / duration.inMicroseconds * 100, 100),
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
thumbColor: Colors.white,
|
|
||||||
activeColor: Colors.white,
|
|
||||||
inactiveColor: whiteOpacity75,
|
|
||||||
onChangeStart: (value) {
|
|
||||||
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
|
||||||
wasPlaying.value = status != VideoPlaybackStatus.paused;
|
|
||||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).pause();
|
|
||||||
},
|
|
||||||
onChangeEnd: (value) {
|
|
||||||
if (wasPlaying.value) {
|
|
||||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).play();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onChanged: (value) {
|
|
||||||
final seekToDuration = (duration * (value / 100.0));
|
|
||||||
|
|
||||||
if (isCasting) {
|
|
||||||
ref.read(castProvider.notifier).seekTo(seekToDuration);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _VideoPositionPlaceholder extends StatelessWidget {
|
|
||||||
const _VideoPositionPlaceholder();
|
|
||||||
|
|
||||||
static void _onChangedDummy(_) {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [FormattedDuration(Duration.zero), FormattedDuration(Duration.zero)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Slider(
|
|
||||||
value: 0.0,
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
thumbColor: Colors.white,
|
|
||||||
activeColor: Colors.white,
|
|
||||||
inactiveColor: whiteOpacity75,
|
|
||||||
onChanged: _onChangedDummy,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,12 @@ class ImmichImage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
return RemoteFullImageProvider(assetId: assetId!, thumbhash: '', assetType: base_asset.AssetType.video);
|
return RemoteFullImageProvider(
|
||||||
|
assetId: assetId!,
|
||||||
|
thumbhash: '',
|
||||||
|
assetType: base_asset.AssetType.video,
|
||||||
|
isAnimated: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useLocal(asset)) {
|
if (useLocal(asset)) {
|
||||||
@@ -43,12 +48,14 @@ class ImmichImage extends StatelessWidget {
|
|||||||
id: asset.localId!,
|
id: asset.localId!,
|
||||||
assetType: base_asset.AssetType.video,
|
assetType: base_asset.AssetType.video,
|
||||||
size: Size(width, height),
|
size: Size(width, height),
|
||||||
|
isAnimated: false,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return RemoteFullImageProvider(
|
return RemoteFullImageProvider(
|
||||||
assetId: asset.remoteId!,
|
assetId: asset.remoteId!,
|
||||||
thumbhash: asset.thumbhash ?? '',
|
thumbhash: asset.thumbhash ?? '',
|
||||||
assetType: base_asset.AssetType.video,
|
assetType: base_asset.AssetType.video,
|
||||||
|
isAnimated: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -26,6 +26,7 @@ class AudioCodec {
|
|||||||
static const mp3 = AudioCodec._(r'mp3');
|
static const mp3 = AudioCodec._(r'mp3');
|
||||||
static const aac = AudioCodec._(r'aac');
|
static const aac = AudioCodec._(r'aac');
|
||||||
static const libopus = AudioCodec._(r'libopus');
|
static const libopus = AudioCodec._(r'libopus');
|
||||||
|
static const opus = AudioCodec._(r'opus');
|
||||||
static const pcmS16le = AudioCodec._(r'pcm_s16le');
|
static const pcmS16le = AudioCodec._(r'pcm_s16le');
|
||||||
|
|
||||||
/// List of all possible values in this [enum][AudioCodec].
|
/// List of all possible values in this [enum][AudioCodec].
|
||||||
@@ -33,6 +34,7 @@ class AudioCodec {
|
|||||||
mp3,
|
mp3,
|
||||||
aac,
|
aac,
|
||||||
libopus,
|
libopus,
|
||||||
|
opus,
|
||||||
pcmS16le,
|
pcmS16le,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ class AudioCodecTypeTransformer {
|
|||||||
case r'mp3': return AudioCodec.mp3;
|
case r'mp3': return AudioCodec.mp3;
|
||||||
case r'aac': return AudioCodec.aac;
|
case r'aac': return AudioCodec.aac;
|
||||||
case r'libopus': return AudioCodec.libopus;
|
case r'libopus': return AudioCodec.libopus;
|
||||||
|
case r'opus': return AudioCodec.opus;
|
||||||
case r'pcm_s16le': return AudioCodec.pcmS16le;
|
case r'pcm_s16le': return AudioCodec.pcmS16le;
|
||||||
default:
|
default:
|
||||||
if (!allowNull) {
|
if (!allowNull) {
|
||||||
|
|||||||
@@ -43,5 +43,5 @@ abstract class NetworkApi {
|
|||||||
|
|
||||||
int getClientPointer();
|
int getClientPointer();
|
||||||
|
|
||||||
void setRequestHeaders(Map<String, String> headers, List<String> serverUrls);
|
void setRequestHeaders(Map<String, String> headers, List<String> serverUrls, String? token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17260,6 +17260,7 @@
|
|||||||
"mp3",
|
"mp3",
|
||||||
"aac",
|
"aac",
|
||||||
"libopus",
|
"libopus",
|
||||||
|
"opus",
|
||||||
"pcm_s16le"
|
"pcm_s16le"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.14",
|
"@types/node": "^24.11.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -7324,6 +7324,7 @@ export enum AudioCodec {
|
|||||||
Mp3 = "mp3",
|
Mp3 = "mp3",
|
||||||
Aac = "aac",
|
Aac = "aac",
|
||||||
Libopus = "libopus",
|
Libopus = "libopus",
|
||||||
|
Opus = "opus",
|
||||||
PcmS16Le = "pcm_s16le"
|
PcmS16Le = "pcm_s16le"
|
||||||
}
|
}
|
||||||
export enum VideoContainer {
|
export enum VideoContainer {
|
||||||
|
|||||||
Generated
+909
-928
File diff suppressed because it is too large
Load Diff
+9
-9
@@ -46,14 +46,14 @@
|
|||||||
"@nestjs/websockets": "^11.0.4",
|
"@nestjs/websockets": "^11.0.4",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||||
"@opentelemetry/exporter-prometheus": "^0.212.0",
|
"@opentelemetry/exporter-prometheus": "^0.213.0",
|
||||||
"@opentelemetry/instrumentation-http": "^0.212.0",
|
"@opentelemetry/instrumentation-http": "^0.213.0",
|
||||||
"@opentelemetry/instrumentation-ioredis": "^0.60.0",
|
"@opentelemetry/instrumentation-ioredis": "^0.61.0",
|
||||||
"@opentelemetry/instrumentation-nestjs-core": "^0.58.0",
|
"@opentelemetry/instrumentation-nestjs-core": "^0.59.0",
|
||||||
"@opentelemetry/instrumentation-pg": "^0.64.0",
|
"@opentelemetry/instrumentation-pg": "^0.65.0",
|
||||||
"@opentelemetry/resources": "^2.0.1",
|
"@opentelemetry/resources": "^2.0.1",
|
||||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||||
"@opentelemetry/sdk-node": "^0.212.0",
|
"@opentelemetry/sdk-node": "^0.213.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||||
"@react-email/components": "^0.5.0",
|
"@react-email/components": "^0.5.0",
|
||||||
"@react-email/render": "^1.1.2",
|
"@react-email/render": "^1.1.2",
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"bullmq": "^5.51.0",
|
"bullmq": "^5.51.0",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.15.0",
|
||||||
"compression": "^1.8.0",
|
"compression": "^1.8.0",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
"jose": "^5.10.0",
|
"jose": "^5.10.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"kysely": "0.28.2",
|
"kysely": "0.28.11",
|
||||||
"kysely-postgres-js": "^3.0.0",
|
"kysely-postgres-js": "^3.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"luxon": "^3.4.2",
|
"luxon": "^3.4.2",
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
"@types/luxon": "^3.6.2",
|
"@types/luxon": "^3.6.2",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^24.10.14",
|
"@types/node": "^24.11.0",
|
||||||
"@types/nodemailer": "^7.0.0",
|
"@types/nodemailer": "^7.0.0",
|
||||||
"@types/picomatch": "^4.0.0",
|
"@types/picomatch": "^4.0.0",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||||||
targetVideoCodec: VideoCodec.H264,
|
targetVideoCodec: VideoCodec.H264,
|
||||||
acceptedVideoCodecs: [VideoCodec.H264],
|
acceptedVideoCodecs: [VideoCodec.H264],
|
||||||
targetAudioCodec: AudioCodec.Aac,
|
targetAudioCodec: AudioCodec.Aac,
|
||||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
|
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus],
|
||||||
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
|
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
|
||||||
targetResolution: '720',
|
targetResolution: '720',
|
||||||
maxBitrate: '0',
|
maxBitrate: '0',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Duration } from 'luxon';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { SemVer } from 'semver';
|
import { SemVer } from 'semver';
|
||||||
import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
||||||
|
|
||||||
export const ErrorMessages = {
|
export const ErrorMessages = {
|
||||||
InconsistentMediaLocation:
|
InconsistentMediaLocation:
|
||||||
@@ -201,3 +201,11 @@ export const endpointTags: Record<ApiTag, string> = {
|
|||||||
[ApiTag.Workflows]:
|
[ApiTag.Workflows]:
|
||||||
'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.',
|
'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const AUDIO_ENCODER: Record<AudioCodec, string> = {
|
||||||
|
[AudioCodec.Aac]: 'aac',
|
||||||
|
[AudioCodec.Mp3]: 'mp3',
|
||||||
|
[AudioCodec.Libopus]: 'libopus',
|
||||||
|
[AudioCodec.Opus]: 'libopus',
|
||||||
|
[AudioCodec.PcmS16le]: 'pcm_s16le',
|
||||||
|
};
|
||||||
|
|||||||
+14
-13
@@ -1,4 +1,4 @@
|
|||||||
import { Selectable } from 'kysely';
|
import { Selectable, ShallowDehydrateObject } from 'kysely';
|
||||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import {
|
import {
|
||||||
AlbumUserRole,
|
AlbumUserRole,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||||
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
|
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
|
||||||
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
|
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||||
import { UserMetadataItem } from 'src/types';
|
import { UserMetadataItem } from 'src/types';
|
||||||
@@ -31,7 +32,7 @@ export type AuthUser = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AlbumUser = {
|
export type AlbumUser = {
|
||||||
user: User;
|
user: ShallowDehydrateObject<User>;
|
||||||
role: AlbumUserRole;
|
role: AlbumUserRole;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ export type Activity = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
albumId: string;
|
albumId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
user: User;
|
user: ShallowDehydrateObject<User>;
|
||||||
assetId: string | null;
|
assetId: string | null;
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
isLiked: boolean;
|
isLiked: boolean;
|
||||||
@@ -105,7 +106,7 @@ export type Memory = {
|
|||||||
data: object;
|
data: object;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
isSaved: boolean;
|
isSaved: boolean;
|
||||||
assets: MapAsset[];
|
assets: ShallowDehydrateObject<MapAsset>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Asset = {
|
export type Asset = {
|
||||||
@@ -159,9 +160,9 @@ export type StorageAsset = {
|
|||||||
export type Stack = {
|
export type Stack = {
|
||||||
id: string;
|
id: string;
|
||||||
primaryAssetId: string;
|
primaryAssetId: string;
|
||||||
owner?: User;
|
owner?: ShallowDehydrateObject<User>;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
assets: MapAsset[];
|
assets: ShallowDehydrateObject<MapAsset>[];
|
||||||
assetCount?: number;
|
assetCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,11 +178,11 @@ export type AuthSharedLink = {
|
|||||||
|
|
||||||
export type SharedLink = {
|
export type SharedLink = {
|
||||||
id: string;
|
id: string;
|
||||||
album?: Album | null;
|
album?: ShallowDehydrateObject<Album> | null;
|
||||||
albumId: string | null;
|
albumId: string | null;
|
||||||
allowDownload: boolean;
|
allowDownload: boolean;
|
||||||
allowUpload: boolean;
|
allowUpload: boolean;
|
||||||
assets: MapAsset[];
|
assets: ShallowDehydrateObject<MapAsset>[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
@@ -194,8 +195,8 @@ export type SharedLink = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Album = Selectable<AlbumTable> & {
|
export type Album = Selectable<AlbumTable> & {
|
||||||
owner: User;
|
owner: ShallowDehydrateObject<User>;
|
||||||
assets: MapAsset[];
|
assets: ShallowDehydrateObject<Selectable<AssetTable>>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthSession = {
|
export type AuthSession = {
|
||||||
@@ -205,9 +206,9 @@ export type AuthSession = {
|
|||||||
|
|
||||||
export type Partner = {
|
export type Partner = {
|
||||||
sharedById: string;
|
sharedById: string;
|
||||||
sharedBy: User;
|
sharedBy: ShallowDehydrateObject<User>;
|
||||||
sharedWithId: string;
|
sharedWithId: string;
|
||||||
sharedWith: User;
|
sharedWith: ShallowDehydrateObject<User>;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
createId: string;
|
createId: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -270,7 +271,7 @@ export type AssetFace = {
|
|||||||
imageWidth: number;
|
imageWidth: number;
|
||||||
personId: string | null;
|
personId: string | null;
|
||||||
sourceType: SourceType;
|
sourceType: SourceType;
|
||||||
person?: Person | null;
|
person?: ShallowDehydrateObject<Person> | null;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
updateId: string;
|
updateId: string;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { mapAlbum } from 'src/dtos/album.dto';
|
import { mapAlbum } from 'src/dtos/album.dto';
|
||||||
import { AlbumFactory } from 'test/factories/album.factory';
|
import { AlbumFactory } from 'test/factories/album.factory';
|
||||||
|
import { getForAlbum } from 'test/mappers';
|
||||||
|
|
||||||
describe('mapAlbum', () => {
|
describe('mapAlbum', () => {
|
||||||
it('should set start and end dates', () => {
|
it('should set start and end dates', () => {
|
||||||
const startDate = new Date('2023-02-22T05:06:29.716Z');
|
const startDate = new Date('2023-02-22T05:06:29.716Z');
|
||||||
const endDate = new Date('2025-01-01T01:02:03.456Z');
|
const endDate = new Date('2025-01-01T01:02:03.456Z');
|
||||||
const album = AlbumFactory.from().asset({ localDateTime: endDate }).asset({ localDateTime: startDate }).build();
|
const album = AlbumFactory.from()
|
||||||
const dto = mapAlbum(album, false);
|
.asset({ localDateTime: endDate }, (builder) => builder.exif())
|
||||||
expect(dto.startDate).toEqual(startDate);
|
.asset({ localDateTime: startDate }, (builder) => builder.exif())
|
||||||
expect(dto.endDate).toEqual(endDate);
|
.build();
|
||||||
|
const dto = mapAlbum(getForAlbum(album), false);
|
||||||
|
expect(dto.startDate).toEqual(startDate.toISOString());
|
||||||
|
expect(dto.endDate).toEqual(endDate.toISOString());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not set start and end dates for empty assets', () => {
|
it('should not set start and end dates for empty assets', () => {
|
||||||
const dto = mapAlbum(AlbumFactory.create(), false);
|
const dto = mapAlbum(getForAlbum(AlbumFactory.create()), false);
|
||||||
expect(dto.startDate).toBeUndefined();
|
expect(dto.startDate).toBeUndefined();
|
||||||
expect(dto.endDate).toBeUndefined();
|
expect(dto.endDate).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
|
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
|
||||||
|
import { ShallowDehydrateObject } from 'kysely';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { AlbumUser, AuthSharedLink, User } from 'src/database';
|
import { AlbumUser, AuthSharedLink, User } from 'src/database';
|
||||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
|
||||||
import { AlbumUserRole, AssetOrder } from 'src/enum';
|
import { AlbumUserRole, AssetOrder } from 'src/enum';
|
||||||
|
import { MaybeDehydrated } from 'src/types';
|
||||||
|
import { asDateString } from 'src/utils/date';
|
||||||
import { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
import { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class AlbumInfoDto {
|
export class AlbumInfoDto {
|
||||||
@@ -151,10 +154,10 @@ export class AlbumResponseDto {
|
|||||||
albumName!: string;
|
albumName!: string;
|
||||||
@ApiProperty({ description: 'Album description' })
|
@ApiProperty({ description: 'Album description' })
|
||||||
description!: string;
|
description!: string;
|
||||||
@ApiProperty({ description: 'Creation date' })
|
@ApiProperty({ description: 'Creation date', format: 'date-time' })
|
||||||
createdAt!: Date;
|
createdAt!: string;
|
||||||
@ApiProperty({ description: 'Last update date' })
|
@ApiProperty({ description: 'Last update date', format: 'date-time' })
|
||||||
updatedAt!: Date;
|
updatedAt!: string;
|
||||||
@ApiProperty({ description: 'Thumbnail asset ID' })
|
@ApiProperty({ description: 'Thumbnail asset ID' })
|
||||||
albumThumbnailAssetId!: string | null;
|
albumThumbnailAssetId!: string | null;
|
||||||
@ApiProperty({ description: 'Is shared album' })
|
@ApiProperty({ description: 'Is shared album' })
|
||||||
@@ -172,12 +175,12 @@ export class AlbumResponseDto {
|
|||||||
owner!: UserResponseDto;
|
owner!: UserResponseDto;
|
||||||
@ApiProperty({ type: 'integer', description: 'Number of assets' })
|
@ApiProperty({ type: 'integer', description: 'Number of assets' })
|
||||||
assetCount!: number;
|
assetCount!: number;
|
||||||
@ApiPropertyOptional({ description: 'Last modified asset timestamp' })
|
@ApiPropertyOptional({ description: 'Last modified asset timestamp', format: 'date-time' })
|
||||||
lastModifiedAssetTimestamp?: Date;
|
lastModifiedAssetTimestamp?: string;
|
||||||
@ApiPropertyOptional({ description: 'Start date (earliest asset)' })
|
@ApiPropertyOptional({ description: 'Start date (earliest asset)', format: 'date-time' })
|
||||||
startDate?: Date;
|
startDate?: string;
|
||||||
@ApiPropertyOptional({ description: 'End date (latest asset)' })
|
@ApiPropertyOptional({ description: 'End date (latest asset)', format: 'date-time' })
|
||||||
endDate?: Date;
|
endDate?: string;
|
||||||
@ApiProperty({ description: 'Activity feed enabled' })
|
@ApiProperty({ description: 'Activity feed enabled' })
|
||||||
isActivityEnabled!: boolean;
|
isActivityEnabled!: boolean;
|
||||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true })
|
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true })
|
||||||
@@ -191,8 +194,8 @@ export class AlbumResponseDto {
|
|||||||
|
|
||||||
export type MapAlbumDto = {
|
export type MapAlbumDto = {
|
||||||
albumUsers?: AlbumUser[];
|
albumUsers?: AlbumUser[];
|
||||||
assets?: MapAsset[];
|
assets?: ShallowDehydrateObject<MapAsset>[];
|
||||||
sharedLinks?: AuthSharedLink[];
|
sharedLinks?: ShallowDehydrateObject<AuthSharedLink>[];
|
||||||
albumName: string;
|
albumName: string;
|
||||||
description: string;
|
description: string;
|
||||||
albumThumbnailAssetId: string | null;
|
albumThumbnailAssetId: string | null;
|
||||||
@@ -200,12 +203,16 @@ export type MapAlbumDto = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
id: string;
|
id: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
owner: User;
|
owner: ShallowDehydrateObject<User>;
|
||||||
isActivityEnabled: boolean;
|
isActivityEnabled: boolean;
|
||||||
order: AssetOrder;
|
order: AssetOrder;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
|
export const mapAlbum = (
|
||||||
|
entity: MaybeDehydrated<MapAlbumDto>,
|
||||||
|
withAssets: boolean,
|
||||||
|
auth?: AuthDto,
|
||||||
|
): AlbumResponseDto => {
|
||||||
const albumUsers: AlbumUserResponseDto[] = [];
|
const albumUsers: AlbumUserResponseDto[] = [];
|
||||||
|
|
||||||
if (entity.albumUsers) {
|
if (entity.albumUsers) {
|
||||||
@@ -236,16 +243,16 @@ export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDt
|
|||||||
albumName: entity.albumName,
|
albumName: entity.albumName,
|
||||||
description: entity.description,
|
description: entity.description,
|
||||||
albumThumbnailAssetId: entity.albumThumbnailAssetId,
|
albumThumbnailAssetId: entity.albumThumbnailAssetId,
|
||||||
createdAt: entity.createdAt,
|
createdAt: asDateString(entity.createdAt),
|
||||||
updatedAt: entity.updatedAt,
|
updatedAt: asDateString(entity.updatedAt),
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
ownerId: entity.ownerId,
|
ownerId: entity.ownerId,
|
||||||
owner: mapUser(entity.owner),
|
owner: mapUser(entity.owner),
|
||||||
albumUsers: albumUsersSorted,
|
albumUsers: albumUsersSorted,
|
||||||
shared: hasSharedUser || hasSharedLink,
|
shared: hasSharedUser || hasSharedLink,
|
||||||
hasSharedLink,
|
hasSharedLink,
|
||||||
startDate,
|
startDate: asDateString(startDate),
|
||||||
endDate,
|
endDate: asDateString(endDate),
|
||||||
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })),
|
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })),
|
||||||
assetCount: entity.assets?.length || 0,
|
assetCount: entity.assets?.length || 0,
|
||||||
isActivityEnabled: entity.isActivityEnabled,
|
isActivityEnabled: entity.isActivityEnabled,
|
||||||
@@ -253,5 +260,5 @@ export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDt
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mapAlbumWithAssets = (entity: MapAlbumDto) => mapAlbum(entity, true);
|
export const mapAlbumWithAssets = (entity: MaybeDehydrated<MapAlbumDto>) => mapAlbum(entity, true);
|
||||||
export const mapAlbumWithoutAssets = (entity: MapAlbumDto) => mapAlbum(entity, false);
|
export const mapAlbumWithoutAssets = (entity: MaybeDehydrated<MapAlbumDto>) => mapAlbum(entity, false);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AssetEditAction } from 'src/dtos/editing.dto';
|
|||||||
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
|
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { PersonFactory } from 'test/factories/person.factory';
|
import { PersonFactory } from 'test/factories/person.factory';
|
||||||
|
import { getForAsset } from 'test/mappers';
|
||||||
|
|
||||||
describe('mapAsset', () => {
|
describe('mapAsset', () => {
|
||||||
describe('peopleWithFaces', () => {
|
describe('peopleWithFaces', () => {
|
||||||
@@ -41,7 +42,7 @@ describe('mapAsset', () => {
|
|||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
const result = mapAsset(asset);
|
const result = mapAsset(getForAsset(asset));
|
||||||
|
|
||||||
expect(result.people).toBeDefined();
|
expect(result.people).toBeDefined();
|
||||||
expect(result.people).toHaveLength(1);
|
expect(result.people).toHaveLength(1);
|
||||||
@@ -80,7 +81,7 @@ describe('mapAsset', () => {
|
|||||||
.edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } })
|
.edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } })
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
const result = mapAsset(asset);
|
const result = mapAsset(getForAsset(asset));
|
||||||
|
|
||||||
expect(result.unassignedFaces).toBeDefined();
|
expect(result.unassignedFaces).toBeDefined();
|
||||||
expect(result.unassignedFaces).toHaveLength(1);
|
expect(result.unassignedFaces).toHaveLength(1);
|
||||||
@@ -130,7 +131,7 @@ describe('mapAsset', () => {
|
|||||||
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
const result = mapAsset(asset);
|
const result = mapAsset(getForAsset(asset));
|
||||||
|
|
||||||
expect(result.people).toBeDefined();
|
expect(result.people).toBeDefined();
|
||||||
expect(result.people).toHaveLength(2);
|
expect(result.people).toHaveLength(2);
|
||||||
@@ -179,7 +180,7 @@ describe('mapAsset', () => {
|
|||||||
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
const result = mapAsset(asset);
|
const result = mapAsset(getForAsset(asset));
|
||||||
|
|
||||||
expect(result.people).toBeDefined();
|
expect(result.people).toBeDefined();
|
||||||
expect(result.people).toHaveLength(1);
|
expect(result.people).toHaveLength(1);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Selectable } from 'kysely';
|
import { Selectable, ShallowDehydrateObject } from 'kysely';
|
||||||
import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
|
import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
|
||||||
import { HistoryBuilder, Property } from 'src/decorators';
|
import { HistoryBuilder, Property } from 'src/decorators';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
@@ -14,9 +14,10 @@ import {
|
|||||||
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
||||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||||
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||||
import { ImageDimensions } from 'src/types';
|
import { ImageDimensions, MaybeDehydrated } from 'src/types';
|
||||||
import { getDimensions } from 'src/utils/asset.util';
|
import { getDimensions } from 'src/utils/asset.util';
|
||||||
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
||||||
|
import { asDateString } from 'src/utils/date';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
import { ValidateEnum, ValidateUUID } from 'src/validation';
|
import { ValidateEnum, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export class SanitizedAssetResponseDto {
|
|||||||
'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.',
|
'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.',
|
||||||
example: '2024-01-15T14:30:00.000Z',
|
example: '2024-01-15T14:30:00.000Z',
|
||||||
})
|
})
|
||||||
localDateTime!: Date;
|
localDateTime!: string;
|
||||||
@ApiProperty({ description: 'Video duration (for videos)' })
|
@ApiProperty({ description: 'Video duration (for videos)' })
|
||||||
duration!: string;
|
duration!: string;
|
||||||
@ApiPropertyOptional({ description: 'Live photo video ID' })
|
@ApiPropertyOptional({ description: 'Live photo video ID' })
|
||||||
@@ -59,7 +60,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
|||||||
description: 'The UTC timestamp when the asset was originally uploaded to Immich.',
|
description: 'The UTC timestamp when the asset was originally uploaded to Immich.',
|
||||||
example: '2024-01-15T20:30:00.000Z',
|
example: '2024-01-15T20:30:00.000Z',
|
||||||
})
|
})
|
||||||
createdAt!: Date;
|
createdAt!: string;
|
||||||
@ApiProperty({ description: 'Device asset ID' })
|
@ApiProperty({ description: 'Device asset ID' })
|
||||||
deviceAssetId!: string;
|
deviceAssetId!: string;
|
||||||
@ApiProperty({ description: 'Device ID' })
|
@ApiProperty({ description: 'Device ID' })
|
||||||
@@ -86,7 +87,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
|||||||
'The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.',
|
'The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.',
|
||||||
example: '2024-01-15T19:30:00.000Z',
|
example: '2024-01-15T19:30:00.000Z',
|
||||||
})
|
})
|
||||||
fileCreatedAt!: Date;
|
fileCreatedAt!: string;
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: 'string',
|
type: 'string',
|
||||||
format: 'date-time',
|
format: 'date-time',
|
||||||
@@ -94,7 +95,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
|||||||
'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.',
|
'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.',
|
||||||
example: '2024-01-16T10:15:00.000Z',
|
example: '2024-01-16T10:15:00.000Z',
|
||||||
})
|
})
|
||||||
fileModifiedAt!: Date;
|
fileModifiedAt!: string;
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: 'string',
|
type: 'string',
|
||||||
format: 'date-time',
|
format: 'date-time',
|
||||||
@@ -102,7 +103,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
|||||||
'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.',
|
'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.',
|
||||||
example: '2024-01-16T12:45:30.000Z',
|
example: '2024-01-16T12:45:30.000Z',
|
||||||
})
|
})
|
||||||
updatedAt!: Date;
|
updatedAt!: string;
|
||||||
@ApiProperty({ description: 'Is favorite' })
|
@ApiProperty({ description: 'Is favorite' })
|
||||||
isFavorite!: boolean;
|
isFavorite!: boolean;
|
||||||
@ApiProperty({ description: 'Is archived' })
|
@ApiProperty({ description: 'Is archived' })
|
||||||
@@ -151,13 +152,13 @@ export type MapAsset = {
|
|||||||
deviceId: string;
|
deviceId: string;
|
||||||
duplicateId: string | null;
|
duplicateId: string | null;
|
||||||
duration: string | null;
|
duration: string | null;
|
||||||
edits?: AssetEditActionItem[];
|
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
|
||||||
encodedVideoPath: string | null;
|
encodedVideoPath: string | null;
|
||||||
exifInfo?: Selectable<Exif> | null;
|
exifInfo?: ShallowDehydrateObject<Selectable<Exif>> | null;
|
||||||
faces?: AssetFace[];
|
faces?: ShallowDehydrateObject<AssetFace>[];
|
||||||
fileCreatedAt: Date;
|
fileCreatedAt: Date;
|
||||||
fileModifiedAt: Date;
|
fileModifiedAt: Date;
|
||||||
files?: AssetFile[];
|
files?: ShallowDehydrateObject<AssetFile>[];
|
||||||
isExternal: boolean;
|
isExternal: boolean;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
isOffline: boolean;
|
isOffline: boolean;
|
||||||
@@ -167,11 +168,11 @@ export type MapAsset = {
|
|||||||
localDateTime: Date;
|
localDateTime: Date;
|
||||||
originalFileName: string;
|
originalFileName: string;
|
||||||
originalPath: string;
|
originalPath: string;
|
||||||
owner?: User | null;
|
owner?: ShallowDehydrateObject<User> | null;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
stack?: Stack | null;
|
stack?: (ShallowDehydrateObject<Stack> & { assets: Stack['assets'] }) | null;
|
||||||
stackId: string | null;
|
stackId: string | null;
|
||||||
tags?: Tag[];
|
tags?: ShallowDehydrateObject<Tag>[];
|
||||||
thumbhash: Buffer<ArrayBufferLike> | null;
|
thumbhash: Buffer<ArrayBufferLike> | null;
|
||||||
type: AssetType;
|
type: AssetType;
|
||||||
width: number | null;
|
width: number | null;
|
||||||
@@ -197,7 +198,7 @@ export type AssetMapOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const peopleWithFaces = (
|
const peopleWithFaces = (
|
||||||
faces?: AssetFace[],
|
faces?: MaybeDehydrated<AssetFace>[],
|
||||||
edits?: AssetEditActionItem[],
|
edits?: AssetEditActionItem[],
|
||||||
assetDimensions?: ImageDimensions,
|
assetDimensions?: ImageDimensions,
|
||||||
): PersonWithFacesResponseDto[] => {
|
): PersonWithFacesResponseDto[] => {
|
||||||
@@ -213,7 +214,10 @@ const peopleWithFaces = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!peopleFaces.has(face.person.id)) {
|
if (!peopleFaces.has(face.person.id)) {
|
||||||
peopleFaces.set(face.person.id, { ...mapPerson(face.person), faces: [] });
|
peopleFaces.set(face.person.id, {
|
||||||
|
...mapPerson(face.person),
|
||||||
|
faces: [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const mappedFace = mapFacesWithoutPerson(face, edits, assetDimensions);
|
const mappedFace = mapFacesWithoutPerson(face, edits, assetDimensions);
|
||||||
peopleFaces.get(face.person.id)!.faces.push(mappedFace);
|
peopleFaces.get(face.person.id)!.faces.push(mappedFace);
|
||||||
@@ -234,7 +238,7 @@ const mapStack = (entity: { stack?: Stack | null }) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto {
|
export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOptions = {}): AssetResponseDto {
|
||||||
const { stripMetadata = false, withStack = false } = options;
|
const { stripMetadata = false, withStack = false } = options;
|
||||||
|
|
||||||
if (stripMetadata) {
|
if (stripMetadata) {
|
||||||
@@ -243,7 +247,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
|||||||
type: entity.type,
|
type: entity.type,
|
||||||
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
||||||
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
||||||
localDateTime: entity.localDateTime,
|
localDateTime: asDateString(entity.localDateTime),
|
||||||
duration: entity.duration ?? '0:00:00.00000',
|
duration: entity.duration ?? '0:00:00.00000',
|
||||||
livePhotoVideoId: entity.livePhotoVideoId,
|
livePhotoVideoId: entity.livePhotoVideoId,
|
||||||
hasMetadata: false,
|
hasMetadata: false,
|
||||||
@@ -257,7 +261,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
createdAt: entity.createdAt,
|
createdAt: asDateString(entity.createdAt),
|
||||||
deviceAssetId: entity.deviceAssetId,
|
deviceAssetId: entity.deviceAssetId,
|
||||||
ownerId: entity.ownerId,
|
ownerId: entity.ownerId,
|
||||||
owner: entity.owner ? mapUser(entity.owner) : undefined,
|
owner: entity.owner ? mapUser(entity.owner) : undefined,
|
||||||
@@ -268,10 +272,10 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
|||||||
originalFileName: entity.originalFileName,
|
originalFileName: entity.originalFileName,
|
||||||
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
||||||
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
||||||
fileCreatedAt: entity.fileCreatedAt,
|
fileCreatedAt: asDateString(entity.fileCreatedAt),
|
||||||
fileModifiedAt: entity.fileModifiedAt,
|
fileModifiedAt: asDateString(entity.fileModifiedAt),
|
||||||
localDateTime: entity.localDateTime,
|
localDateTime: asDateString(entity.localDateTime),
|
||||||
updatedAt: entity.updatedAt,
|
updatedAt: asDateString(entity.updatedAt),
|
||||||
isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite,
|
isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite,
|
||||||
isArchived: entity.visibility === AssetVisibility.Archive,
|
isArchived: entity.visibility === AssetVisibility.Archive,
|
||||||
isTrashed: !!entity.deletedAt,
|
isTrashed: !!entity.deletedAt,
|
||||||
@@ -283,7 +287,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
|||||||
people: peopleWithFaces(entity.faces, entity.edits, assetDimensions),
|
people: peopleWithFaces(entity.faces, entity.edits, assetDimensions),
|
||||||
unassignedFaces: entity.faces
|
unassignedFaces: entity.faces
|
||||||
?.filter((face) => !face.person)
|
?.filter((face) => !face.person)
|
||||||
.map((a) => mapFacesWithoutPerson(a, entity.edits, assetDimensions)),
|
.map((face) => mapFacesWithoutPerson(face, entity.edits, assetDimensions)),
|
||||||
checksum: hexOrBufferToBase64(entity.checksum)!,
|
checksum: hexOrBufferToBase64(entity.checksum)!,
|
||||||
stack: withStack ? mapStack(entity) : undefined,
|
stack: withStack ? mapStack(entity) : undefined,
|
||||||
isOffline: entity.isOffline,
|
isOffline: entity.isOffline,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Exif } from 'src/database';
|
import { Exif } from 'src/database';
|
||||||
|
import { MaybeDehydrated } from 'src/types';
|
||||||
|
import { asDateString } from 'src/utils/date';
|
||||||
|
|
||||||
export class ExifResponseDto {
|
export class ExifResponseDto {
|
||||||
@ApiPropertyOptional({ description: 'Camera make' })
|
@ApiPropertyOptional({ description: 'Camera make' })
|
||||||
@@ -16,9 +18,9 @@ export class ExifResponseDto {
|
|||||||
@ApiPropertyOptional({ description: 'Image orientation' })
|
@ApiPropertyOptional({ description: 'Image orientation' })
|
||||||
orientation?: string | null = null;
|
orientation?: string | null = null;
|
||||||
@ApiPropertyOptional({ description: 'Original date/time', format: 'date-time' })
|
@ApiPropertyOptional({ description: 'Original date/time', format: 'date-time' })
|
||||||
dateTimeOriginal?: Date | null = null;
|
dateTimeOriginal?: string | null = null;
|
||||||
@ApiPropertyOptional({ description: 'Modification date/time', format: 'date-time' })
|
@ApiPropertyOptional({ description: 'Modification date/time', format: 'date-time' })
|
||||||
modifyDate?: Date | null = null;
|
modifyDate?: string | null = null;
|
||||||
@ApiPropertyOptional({ description: 'Time zone' })
|
@ApiPropertyOptional({ description: 'Time zone' })
|
||||||
timeZone?: string | null = null;
|
timeZone?: string | null = null;
|
||||||
@ApiPropertyOptional({ description: 'Lens model' })
|
@ApiPropertyOptional({ description: 'Lens model' })
|
||||||
@@ -49,7 +51,7 @@ export class ExifResponseDto {
|
|||||||
rating?: number | null = null;
|
rating?: number | null = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapExif(entity: Exif): ExifResponseDto {
|
export function mapExif(entity: MaybeDehydrated<Exif>): ExifResponseDto {
|
||||||
return {
|
return {
|
||||||
make: entity.make,
|
make: entity.make,
|
||||||
model: entity.model,
|
model: entity.model,
|
||||||
@@ -57,8 +59,8 @@ export function mapExif(entity: Exif): ExifResponseDto {
|
|||||||
exifImageHeight: entity.exifImageHeight,
|
exifImageHeight: entity.exifImageHeight,
|
||||||
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
||||||
orientation: entity.orientation,
|
orientation: entity.orientation,
|
||||||
dateTimeOriginal: entity.dateTimeOriginal,
|
dateTimeOriginal: asDateString(entity.dateTimeOriginal),
|
||||||
modifyDate: entity.modifyDate,
|
modifyDate: asDateString(entity.modifyDate),
|
||||||
timeZone: entity.timeZone,
|
timeZone: entity.timeZone,
|
||||||
lensModel: entity.lensModel,
|
lensModel: entity.lensModel,
|
||||||
fNumber: entity.fNumber,
|
fNumber: entity.fNumber,
|
||||||
@@ -80,7 +82,7 @@ export function mapSanitizedExif(entity: Exif): ExifResponseDto {
|
|||||||
return {
|
return {
|
||||||
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
||||||
orientation: entity.orientation,
|
orientation: entity.orientation,
|
||||||
dateTimeOriginal: entity.dateTimeOriginal,
|
dateTimeOriginal: asDateString(entity.dateTimeOriginal),
|
||||||
timeZone: entity.timeZone,
|
timeZone: entity.timeZone,
|
||||||
projectionType: entity.projectionType,
|
projectionType: entity.projectionType,
|
||||||
exifImageWidth: entity.exifImageWidth,
|
exifImageWidth: entity.exifImageWidth,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
|||||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||||
import { SourceType } from 'src/enum';
|
import { SourceType } from 'src/enum';
|
||||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||||
import { ImageDimensions } from 'src/types';
|
import { ImageDimensions, MaybeDehydrated } from 'src/types';
|
||||||
import { asDateString } from 'src/utils/date';
|
import { asBirthDateString, asDateString } from 'src/utils/date';
|
||||||
import { transformFaceBoundingBox } from 'src/utils/transform';
|
import { transformFaceBoundingBox } from 'src/utils/transform';
|
||||||
import {
|
import {
|
||||||
IsDateStringFormat,
|
IsDateStringFormat,
|
||||||
@@ -33,7 +33,7 @@ export class PersonCreateDto {
|
|||||||
@MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' })
|
@MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' })
|
||||||
@IsDateStringFormat('yyyy-MM-dd')
|
@IsDateStringFormat('yyyy-MM-dd')
|
||||||
@Optional({ nullable: true, emptyToNull: true })
|
@Optional({ nullable: true, emptyToNull: true })
|
||||||
birthDate?: Date | null;
|
birthDate?: string | null;
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true, description: 'Person visibility (hidden)' })
|
@ValidateBoolean({ optional: true, description: 'Person visibility (hidden)' })
|
||||||
isHidden?: boolean;
|
isHidden?: boolean;
|
||||||
@@ -105,8 +105,12 @@ export class PersonResponseDto {
|
|||||||
thumbnailPath!: string;
|
thumbnailPath!: string;
|
||||||
@ApiProperty({ description: 'Is hidden' })
|
@ApiProperty({ description: 'Is hidden' })
|
||||||
isHidden!: boolean;
|
isHidden!: boolean;
|
||||||
@Property({ description: 'Last update date', history: new HistoryBuilder().added('v1.107.0').stable('v2') })
|
@Property({
|
||||||
updatedAt?: Date;
|
description: 'Last update date',
|
||||||
|
format: 'date-time',
|
||||||
|
history: new HistoryBuilder().added('v1.107.0').stable('v2'),
|
||||||
|
})
|
||||||
|
updatedAt?: string;
|
||||||
@Property({ description: 'Is favorite', history: new HistoryBuilder().added('v1.126.0').stable('v2') })
|
@Property({ description: 'Is favorite', history: new HistoryBuilder().added('v1.126.0').stable('v2') })
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
@Property({ description: 'Person color (hex)', history: new HistoryBuilder().added('v1.126.0').stable('v2') })
|
@Property({ description: 'Person color (hex)', history: new HistoryBuilder().added('v1.126.0').stable('v2') })
|
||||||
@@ -222,21 +226,21 @@ export class PeopleResponseDto {
|
|||||||
hasNextPage?: boolean;
|
hasNextPage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapPerson(person: Person): PersonResponseDto {
|
export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
|
||||||
return {
|
return {
|
||||||
id: person.id,
|
id: person.id,
|
||||||
name: person.name,
|
name: person.name,
|
||||||
birthDate: asDateString(person.birthDate),
|
birthDate: asBirthDateString(person.birthDate),
|
||||||
thumbnailPath: person.thumbnailPath,
|
thumbnailPath: person.thumbnailPath,
|
||||||
isHidden: person.isHidden,
|
isHidden: person.isHidden,
|
||||||
isFavorite: person.isFavorite,
|
isFavorite: person.isFavorite,
|
||||||
color: person.color ?? undefined,
|
color: person.color ?? undefined,
|
||||||
updatedAt: person.updatedAt,
|
updatedAt: asDateString(person.updatedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapFacesWithoutPerson(
|
export function mapFacesWithoutPerson(
|
||||||
face: Selectable<AssetFaceTable>,
|
face: MaybeDehydrated<Selectable<AssetFaceTable>>,
|
||||||
edits?: AssetEditActionItem[],
|
edits?: AssetEditActionItem[],
|
||||||
assetDimensions?: ImageDimensions,
|
assetDimensions?: ImageDimensions,
|
||||||
): AssetFaceWithoutPersonResponseDto {
|
): AssetFaceWithoutPersonResponseDto {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsInt,
|
IsInt,
|
||||||
@@ -92,6 +92,16 @@ export class SystemConfigFFmpegDto {
|
|||||||
targetAudioCodec!: AudioCodec;
|
targetAudioCodec!: AudioCodec;
|
||||||
|
|
||||||
@ValidateEnum({ enum: AudioCodec, name: 'AudioCodec', each: true, description: 'Accepted audio codecs' })
|
@ValidateEnum({ enum: AudioCodec, name: 'AudioCodec', each: true, description: 'Accepted audio codecs' })
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const libopusIndex = value.indexOf('libopus');
|
||||||
|
if (libopusIndex !== -1) {
|
||||||
|
value[libopusIndex] = 'opus';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
})
|
||||||
acceptedAudioCodecs!: AudioCodec[];
|
acceptedAudioCodecs!: AudioCodec[];
|
||||||
|
|
||||||
@ValidateEnum({ enum: VideoContainer, name: 'VideoContainer', each: true, description: 'Accepted containers' })
|
@ValidateEnum({ enum: VideoContainer, name: 'VideoContainer', each: true, description: 'Accepted containers' })
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
|
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
|
||||||
import { Tag } from 'src/database';
|
import { Tag } from 'src/database';
|
||||||
|
import { MaybeDehydrated } from 'src/types';
|
||||||
|
import { asDateString } from 'src/utils/date';
|
||||||
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
|
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class TagCreateDto {
|
export class TagCreateDto {
|
||||||
@@ -54,22 +56,22 @@ export class TagResponseDto {
|
|||||||
name!: string;
|
name!: string;
|
||||||
@ApiProperty({ description: 'Tag value (full path)' })
|
@ApiProperty({ description: 'Tag value (full path)' })
|
||||||
value!: string;
|
value!: string;
|
||||||
@ApiProperty({ description: 'Creation date' })
|
@ApiProperty({ description: 'Creation date', format: 'date-time' })
|
||||||
createdAt!: Date;
|
createdAt!: string;
|
||||||
@ApiProperty({ description: 'Last update date' })
|
@ApiProperty({ description: 'Last update date', format: 'date-time' })
|
||||||
updatedAt!: Date;
|
updatedAt!: string;
|
||||||
@ApiPropertyOptional({ description: 'Tag color (hex)' })
|
@ApiPropertyOptional({ description: 'Tag color (hex)' })
|
||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapTag(entity: Tag): TagResponseDto {
|
export function mapTag(entity: MaybeDehydrated<Tag>): TagResponseDto {
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
parentId: entity.parentId ?? undefined,
|
parentId: entity.parentId ?? undefined,
|
||||||
name: entity.value.split('/').at(-1) as string,
|
name: entity.value.split('/').at(-1) as string,
|
||||||
value: entity.value,
|
value: entity.value,
|
||||||
createdAt: entity.createdAt,
|
createdAt: asDateString(entity.createdAt),
|
||||||
updatedAt: entity.updatedAt,
|
updatedAt: asDateString(entity.updatedAt),
|
||||||
color: entity.color ?? undefined,
|
color: entity.color ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Transform } from 'class-transformer';
|
|||||||
import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
|
import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
|
||||||
import { User, UserAdmin } from 'src/database';
|
import { User, UserAdmin } from 'src/database';
|
||||||
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
||||||
import { UserMetadataItem } from 'src/types';
|
import { MaybeDehydrated, UserMetadataItem } from 'src/types';
|
||||||
|
import { asDateString } from 'src/utils/date';
|
||||||
import { Optional, PinCode, ValidateBoolean, ValidateEnum, ValidateUUID, toEmail, toSanitized } from 'src/validation';
|
import { Optional, PinCode, ValidateBoolean, ValidateEnum, ValidateUUID, toEmail, toSanitized } from 'src/validation';
|
||||||
|
|
||||||
export class UserUpdateMeDto {
|
export class UserUpdateMeDto {
|
||||||
@@ -47,8 +48,8 @@ export class UserResponseDto {
|
|||||||
profileImagePath!: string;
|
profileImagePath!: string;
|
||||||
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', description: 'Avatar color' })
|
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', description: 'Avatar color' })
|
||||||
avatarColor!: UserAvatarColor;
|
avatarColor!: UserAvatarColor;
|
||||||
@ApiProperty({ description: 'Profile change date' })
|
@ApiProperty({ description: 'Profile change date', format: 'date-time' })
|
||||||
profileChangedAt!: Date;
|
profileChangedAt!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserLicense {
|
export class UserLicense {
|
||||||
@@ -68,14 +69,14 @@ const emailToAvatarColor = (email: string): UserAvatarColor => {
|
|||||||
return values[randomIndex];
|
return values[randomIndex];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mapUser = (entity: User | UserAdmin): UserResponseDto => {
|
export const mapUser = (entity: MaybeDehydrated<User | UserAdmin>): UserResponseDto => {
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
email: entity.email,
|
email: entity.email,
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
profileImagePath: entity.profileImagePath,
|
profileImagePath: entity.profileImagePath,
|
||||||
avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email),
|
avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email),
|
||||||
profileChangedAt: entity.profileChangedAt,
|
profileChangedAt: asDateString(entity.profileChangedAt),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -409,7 +409,9 @@ export enum VideoCodec {
|
|||||||
export enum AudioCodec {
|
export enum AudioCodec {
|
||||||
Mp3 = 'mp3',
|
Mp3 = 'mp3',
|
||||||
Aac = 'aac',
|
Aac = 'aac',
|
||||||
LibOpus = 'libopus',
|
/** @deprecated Use `Opus` instead */
|
||||||
|
Libopus = 'libopus',
|
||||||
|
Opus = 'opus',
|
||||||
PcmS16le = 'pcm_s16le',
|
PcmS16le = 'pcm_s16le',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
|||||||
import { RouteKey } from 'src/enum';
|
import { RouteKey } from 'src/enum';
|
||||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||||
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
|
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
|
||||||
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
|
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
|
||||||
@@ -54,6 +55,7 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||||||
constructor(
|
constructor(
|
||||||
private reflect: Reflector,
|
private reflect: Reflector,
|
||||||
private assetService: AssetMediaService,
|
private assetService: AssetMediaService,
|
||||||
|
private storageRepository: StorageRepository,
|
||||||
private logger: LoggingRepository,
|
private logger: LoggingRepository,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(FileUploadInterceptor.name);
|
this.logger.setContext(FileUploadInterceptor.name);
|
||||||
@@ -125,7 +127,18 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!this.isAssetUploadFile(file)) {
|
if (!this.isAssetUploadFile(file)) {
|
||||||
this.defaultStorage._handleFile(request, file, callback);
|
this.defaultStorage._handleFile(request, file, (error, info) => {
|
||||||
|
if (error) {
|
||||||
|
return callback(error);
|
||||||
|
}
|
||||||
|
// Multer does not sync files to disk after writing.
|
||||||
|
//
|
||||||
|
// TODO: use `flush: true` in multer when available: https://github.com/expressjs/multer/issues/1381
|
||||||
|
this.storageRepository
|
||||||
|
.datasync(info!.path!)
|
||||||
|
.then(() => callback(null, info!))
|
||||||
|
.catch((error) => callback(error));
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +149,13 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||||||
hash.destroy();
|
hash.destroy();
|
||||||
callback(error);
|
callback(error);
|
||||||
} else {
|
} else {
|
||||||
callback(null, { ...info, checksum: hash.digest() });
|
this.storageRepository
|
||||||
|
.datasync(info!.path!)
|
||||||
|
.then(() => callback(null, { ...info, checksum: hash.digest() }))
|
||||||
|
.catch((error) => {
|
||||||
|
hash.destroy();
|
||||||
|
callback(error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ with
|
|||||||
and "stack"."primaryAssetId" != "asset"."id"
|
and "stack"."primaryAssetId" != "asset"."id"
|
||||||
)
|
)
|
||||||
order by
|
order by
|
||||||
|
(asset."localDateTime" AT TIME ZONE 'UTC')::date desc,
|
||||||
"asset"."fileCreatedAt" desc
|
"asset"."fileCreatedAt" desc
|
||||||
),
|
),
|
||||||
"agg" as (
|
"agg" as (
|
||||||
|
|||||||
@@ -244,3 +244,37 @@ where
|
|||||||
or "album"."id" is not null
|
or "album"."id" is not null
|
||||||
)
|
)
|
||||||
and "shared_link"."slug" = $2
|
and "shared_link"."slug" = $2
|
||||||
|
|
||||||
|
-- SharedLinkRepository.getSharedLinks
|
||||||
|
select
|
||||||
|
"shared_link".*,
|
||||||
|
coalesce(
|
||||||
|
json_agg("assets") filter (
|
||||||
|
where
|
||||||
|
"assets"."id" is not null
|
||||||
|
),
|
||||||
|
'[]'
|
||||||
|
) as "assets"
|
||||||
|
from
|
||||||
|
"shared_link"
|
||||||
|
left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id"
|
||||||
|
left join lateral (
|
||||||
|
select
|
||||||
|
"asset".*
|
||||||
|
from
|
||||||
|
"asset"
|
||||||
|
inner join lateral (
|
||||||
|
select
|
||||||
|
*
|
||||||
|
from
|
||||||
|
"asset_exif"
|
||||||
|
where
|
||||||
|
"asset_exif"."assetId" = "asset"."id"
|
||||||
|
) as "exifInfo" on true
|
||||||
|
where
|
||||||
|
"asset"."id" = "shared_link_asset"."assetId"
|
||||||
|
) as "assets" on true
|
||||||
|
where
|
||||||
|
"shared_link"."id" = $1
|
||||||
|
group by
|
||||||
|
"shared_link"."id"
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ExpressionBuilder, Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
|
import {
|
||||||
|
ExpressionBuilder,
|
||||||
|
Insertable,
|
||||||
|
Kysely,
|
||||||
|
NotNull,
|
||||||
|
Selectable,
|
||||||
|
ShallowDehydrateObject,
|
||||||
|
sql,
|
||||||
|
Updateable,
|
||||||
|
} from 'kysely';
|
||||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { columns, Exif } from 'src/database';
|
import { columns } from 'src/database';
|
||||||
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
|
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||||
|
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||||
import { withDefaultVisibility } from 'src/utils/database';
|
import { withDefaultVisibility } from 'src/utils/database';
|
||||||
|
|
||||||
export interface AlbumAssetCount {
|
export interface AlbumAssetCount {
|
||||||
@@ -56,7 +66,9 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
|
|||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.selectAll('asset')
|
.selectAll('asset')
|
||||||
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||||
.select((eb) => eb.table('asset_exif').$castTo<Exif>().as('exifInfo'))
|
.select((eb) =>
|
||||||
|
eb.table('asset_exif').$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>().as('exifInfo'),
|
||||||
|
)
|
||||||
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
|
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
|
||||||
.whereRef('album_asset.albumId', '=', 'album.id')
|
.whereRef('album_asset.albumId', '=', 'album.id')
|
||||||
.where('asset.deletedAt', 'is', null)
|
.where('asset.deletedAt', 'is', null)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { DB } from 'src/schema';
|
|||||||
import {
|
import {
|
||||||
anyUuid,
|
anyUuid,
|
||||||
asUuid,
|
asUuid,
|
||||||
toJson,
|
|
||||||
withDefaultVisibility,
|
withDefaultVisibility,
|
||||||
withEdits,
|
withEdits,
|
||||||
withExif,
|
withExif,
|
||||||
@@ -296,7 +295,12 @@ export class AssetJobRepository {
|
|||||||
.as('stack_result'),
|
.as('stack_result'),
|
||||||
(join) => join.onTrue(),
|
(join) => join.onTrue(),
|
||||||
)
|
)
|
||||||
.select((eb) => toJson(eb, 'stack_result').as('stack'))
|
.select((eb) =>
|
||||||
|
eb.fn
|
||||||
|
.toJson(eb.table('stack_result'))
|
||||||
|
.$castTo<{ id: string; primaryAssetId: string; assets: { id: string }[] } | null>()
|
||||||
|
.as('stack'),
|
||||||
|
)
|
||||||
.where('asset.id', '=', id)
|
.where('asset.id', '=', id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
NotNull,
|
NotNull,
|
||||||
Selectable,
|
Selectable,
|
||||||
SelectQueryBuilder,
|
SelectQueryBuilder,
|
||||||
|
ShallowDehydrateObject,
|
||||||
sql,
|
sql,
|
||||||
Updateable,
|
Updateable,
|
||||||
UpdateResult,
|
UpdateResult,
|
||||||
@@ -554,7 +555,11 @@ export class AssetRepository {
|
|||||||
eb
|
eb
|
||||||
.selectFrom('asset as stacked')
|
.selectFrom('asset as stacked')
|
||||||
.selectAll('stack')
|
.selectAll('stack')
|
||||||
.select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets'))
|
.select((eb) =>
|
||||||
|
eb
|
||||||
|
.fn<ShallowDehydrateObject<Selectable<AssetTable>>>('array_agg', [eb.table('stacked')])
|
||||||
|
.as('assets'),
|
||||||
|
)
|
||||||
.whereRef('stacked.stackId', '=', 'stack.id')
|
.whereRef('stacked.stackId', '=', 'stack.id')
|
||||||
.whereRef('stacked.id', '!=', 'stack.primaryAssetId')
|
.whereRef('stacked.id', '!=', 'stack.primaryAssetId')
|
||||||
.where('stacked.deletedAt', 'is', null)
|
.where('stacked.deletedAt', 'is', null)
|
||||||
@@ -563,7 +568,7 @@ export class AssetRepository {
|
|||||||
.as('stacked_assets'),
|
.as('stacked_assets'),
|
||||||
(join) => join.on('stack.id', 'is not', null),
|
(join) => join.on('stack.id', 'is not', null),
|
||||||
)
|
)
|
||||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo<Stack | null>().as('stack')),
|
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.$if(!!files, (qb) => qb.select(withFiles))
|
.$if(!!files, (qb) => qb.select(withFiles))
|
||||||
@@ -744,6 +749,7 @@ export class AssetRepository {
|
|||||||
params: [DummyValue.TIME_BUCKET, { withStacked: true }, { user: { id: DummyValue.UUID } }],
|
params: [DummyValue.TIME_BUCKET, { withStacked: true }, { user: { id: DummyValue.UUID } }],
|
||||||
})
|
})
|
||||||
getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) {
|
getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) {
|
||||||
|
const order = options.order ?? 'desc';
|
||||||
const query = this.db
|
const query = this.db
|
||||||
.with('cte', (qb) =>
|
.with('cte', (qb) =>
|
||||||
qb
|
qb
|
||||||
@@ -841,7 +847,8 @@ export class AssetRepository {
|
|||||||
)
|
)
|
||||||
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
|
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
|
||||||
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
||||||
.orderBy('asset.fileCreatedAt', options.order ?? 'desc'),
|
.orderBy(sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`, order)
|
||||||
|
.orderBy('asset.fileCreatedAt', order),
|
||||||
)
|
)
|
||||||
.with('agg', (qb) =>
|
.with('agg', (qb) =>
|
||||||
qb
|
qb
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Kysely, NotNull, sql } from 'kysely';
|
import { Kysely, NotNull, Selectable, ShallowDehydrateObject, sql } from 'kysely';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { Chunked, DummyValue, GenerateSql } from 'src/decorators';
|
import { Chunked, DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
|
||||||
import { AssetType, VectorIndex } from 'src/enum';
|
import { AssetType, VectorIndex } from 'src/enum';
|
||||||
import { probes } from 'src/repositories/database.repository';
|
import { probes } from 'src/repositories/database.repository';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
|
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||||
import { anyUuid, asUuid, withDefaultVisibility } from 'src/utils/database';
|
import { anyUuid, asUuid, withDefaultVisibility } from 'src/utils/database';
|
||||||
|
|
||||||
interface DuplicateSearch {
|
interface DuplicateSearch {
|
||||||
@@ -39,15 +39,15 @@ export class DuplicateRepository {
|
|||||||
qb
|
qb
|
||||||
.selectFrom('asset_exif')
|
.selectFrom('asset_exif')
|
||||||
.selectAll('asset')
|
.selectAll('asset')
|
||||||
.select((eb) => eb.table('asset_exif').as('exifInfo'))
|
.select((eb) =>
|
||||||
|
eb.table('asset_exif').$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>().as('exifInfo'),
|
||||||
|
)
|
||||||
.whereRef('asset_exif.assetId', '=', 'asset.id')
|
.whereRef('asset_exif.assetId', '=', 'asset.id')
|
||||||
.as('asset2'),
|
.as('asset2'),
|
||||||
(join) => join.onTrue(),
|
(join) => join.onTrue(),
|
||||||
)
|
)
|
||||||
.select('asset.duplicateId')
|
.select('asset.duplicateId')
|
||||||
.select((eb) =>
|
.select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').as('assets'))
|
||||||
eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo<MapAsset[]>().as('assets'),
|
|
||||||
)
|
|
||||||
.where('asset.ownerId', '=', asUuid(userId))
|
.where('asset.ownerId', '=', asUuid(userId))
|
||||||
.where('asset.duplicateId', 'is not', null)
|
.where('asset.duplicateId', 'is not', null)
|
||||||
.$narrowType<{ duplicateId: NotNull }>()
|
.$narrowType<{ duplicateId: NotNull }>()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SourceType } from 'src/enum';
|
|||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
||||||
import { MediaRepository } from 'src/repositories/media.repository';
|
import { MediaRepository } from 'src/repositories/media.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
||||||
import { automock } from 'test/utils';
|
import { automock } from 'test/utils';
|
||||||
|
|
||||||
@@ -65,8 +66,11 @@ describe(MediaRepository.name, () => {
|
|||||||
let sut: MediaRepository;
|
let sut: MediaRepository;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
sut = new MediaRepository(
|
||||||
// eslint-disable-next-line no-sparse-arrays
|
// eslint-disable-next-line no-sparse-arrays
|
||||||
sut = new MediaRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }));
|
automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }),
|
||||||
|
automock(StorageRepository, { args: [{ setContext: () => {} }], strict: false }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('applyEdits (single actions)', () => {
|
describe('applyEdits (single actions)', () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Exif } from 'src/database';
|
|||||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||||
import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum';
|
import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import {
|
import {
|
||||||
DecodeToBufferOptions,
|
DecodeToBufferOptions,
|
||||||
GenerateThumbhashOptions,
|
GenerateThumbhashOptions,
|
||||||
@@ -45,7 +46,10 @@ export type ExtractResult = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaRepository {
|
export class MediaRepository {
|
||||||
constructor(private logger: LoggingRepository) {
|
constructor(
|
||||||
|
private logger: LoggingRepository,
|
||||||
|
private storageRepository: StorageRepository,
|
||||||
|
) {
|
||||||
this.logger.setContext(MediaRepository.name);
|
this.logger.setContext(MediaRepository.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +120,7 @@ export class MediaRepository {
|
|||||||
ignoreMinorErrors: true,
|
ignoreMinorErrors: true,
|
||||||
writeArgs: ['-overwrite_original'],
|
writeArgs: ['-overwrite_original'],
|
||||||
});
|
});
|
||||||
|
await this.storageRepository.datasync(output);
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.warn(`Could not write exif data to image: ${error.message}`);
|
this.logger.warn(`Could not write exif data to image: ${error.message}`);
|
||||||
@@ -133,6 +138,7 @@ export class MediaRepository {
|
|||||||
writeArgs: ['-TagsFromFile', source, `-${tagGroup}:all>${tagGroup}:all`, '-overwrite_original'],
|
writeArgs: ['-TagsFromFile', source, `-${tagGroup}:all>${tagGroup}:all`, '-overwrite_original'],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
await this.storageRepository.datasync(target);
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.warn(`Could not copy tag data to image: ${error.message}`);
|
this.logger.warn(`Could not copy tag data to image: ${error.message}`);
|
||||||
@@ -180,6 +186,7 @@ export class MediaRepository {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await decoded.toFile(output);
|
await decoded.toFile(output);
|
||||||
|
await this.storageRepository.datasync(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||||
@@ -274,14 +281,18 @@ export class MediaRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
|
async transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
|
||||||
if (!options.twoPass) {
|
if (!options.twoPass) {
|
||||||
return new Promise((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
this.configureFfmpegCall(input, output, options)
|
this.configureFfmpegCall(input, output, options)
|
||||||
.on('error', reject)
|
.on('error', reject)
|
||||||
.on('end', () => resolve())
|
.on('end', () => resolve())
|
||||||
.run();
|
.run();
|
||||||
});
|
});
|
||||||
|
if (typeof output === 'string') {
|
||||||
|
await this.storageRepository.datasync(output);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof output !== 'string') {
|
if (typeof output !== 'string') {
|
||||||
@@ -290,7 +301,7 @@ export class MediaRepository {
|
|||||||
|
|
||||||
// two-pass allows for precise control of bitrate at the cost of running twice
|
// two-pass allows for precise control of bitrate at the cost of running twice
|
||||||
// recommended for vp9 for better quality and compression
|
// recommended for vp9 for better quality and compression
|
||||||
return new Promise((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
// first pass output is not saved as only the .log file is needed
|
// first pass output is not saved as only the .log file is needed
|
||||||
this.configureFfmpegCall(input, '/dev/null', options)
|
this.configureFfmpegCall(input, '/dev/null', options)
|
||||||
.addOptions('-pass', '1')
|
.addOptions('-pass', '1')
|
||||||
@@ -310,6 +321,7 @@ export class MediaRepository {
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
});
|
});
|
||||||
|
await this.storageRepository.datasync(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getImageMetadata(input: string | Buffer): Promise<ImageDimensions & { isTransparent: boolean }> {
|
async getImageMetadata(input: string | Buffer): Promise<ImageDimensions & { isTransparent: boolean }> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
|
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
|
||||||
import geotz from 'geo-tz';
|
import geotz from 'geo-tz';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
|
|
||||||
interface ExifDuration {
|
interface ExifDuration {
|
||||||
@@ -72,6 +73,8 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
|||||||
|
|
||||||
AndroidMake?: string;
|
AndroidMake?: string;
|
||||||
AndroidModel?: string;
|
AndroidModel?: string;
|
||||||
|
DeviceManufacturer?: string;
|
||||||
|
DeviceModelName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -92,7 +95,10 @@ export class MetadataRepository {
|
|||||||
taskTimeoutMillis: 2 * 60 * 1000,
|
taskTimeoutMillis: 2 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor(private logger: LoggingRepository) {
|
constructor(
|
||||||
|
private logger: LoggingRepository,
|
||||||
|
private storageRepository: StorageRepository,
|
||||||
|
) {
|
||||||
this.logger.setContext(MetadataRepository.name);
|
this.logger.setContext(MetadataRepository.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +125,7 @@ export class MetadataRepository {
|
|||||||
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.exiftool.write(path, tags);
|
await this.exiftool.write(path, tags);
|
||||||
|
await this.storageRepository.datasync(path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Kysely, OrderByDirection, Selectable, sql } from 'kysely';
|
import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
@@ -433,7 +433,7 @@ export class SearchRepository {
|
|||||||
.select((eb) =>
|
.select((eb) =>
|
||||||
eb
|
eb
|
||||||
.fn('to_jsonb', [eb.table('asset_exif')])
|
.fn('to_jsonb', [eb.table('asset_exif')])
|
||||||
.$castTo<Selectable<AssetExifTable>>()
|
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>()
|
||||||
.as('exifInfo'),
|
.as('exifInfo'),
|
||||||
)
|
)
|
||||||
.orderBy('asset_exif.city')
|
.orderBy('asset_exif.city')
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Insertable, Kysely, sql, Updateable } from 'kysely';
|
import { Insertable, Kysely, Selectable, ShallowDehydrateObject, sql, Updateable } from 'kysely';
|
||||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { Album, columns } from 'src/database';
|
import { Album, columns } from 'src/database';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
|
||||||
import { SharedLinkType } from 'src/enum';
|
import { SharedLinkType } from 'src/enum';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
|
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||||
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||||
|
|
||||||
export type SharedLinkSearchOptions = {
|
export type SharedLinkSearchOptions = {
|
||||||
@@ -106,11 +107,15 @@ export class SharedLinkRepository {
|
|||||||
.select((eb) =>
|
.select((eb) =>
|
||||||
eb.fn
|
eb.fn
|
||||||
.coalesce(eb.fn.jsonAgg('a').filterWhere('a.id', 'is not', null), sql`'[]'`)
|
.coalesce(eb.fn.jsonAgg('a').filterWhere('a.id', 'is not', null), sql`'[]'`)
|
||||||
.$castTo<MapAsset[]>()
|
.$castTo<
|
||||||
|
(ShallowDehydrateObject<Selectable<AssetTable>> & {
|
||||||
|
exifInfo: ShallowDehydrateObject<Selectable<AssetExifTable>>;
|
||||||
|
})[]
|
||||||
|
>()
|
||||||
.as('assets'),
|
.as('assets'),
|
||||||
)
|
)
|
||||||
.groupBy(['shared_link.id', sql`"album".*`])
|
.groupBy(['shared_link.id', sql`"album".*`])
|
||||||
.select((eb) => eb.fn.toJson('album').$castTo<Album | null>().as('album'))
|
.select((eb) => eb.fn.toJson(eb.table('album')).$castTo<ShallowDehydrateObject<Album> | null>().as('album'))
|
||||||
.where('shared_link.id', '=', id)
|
.where('shared_link.id', '=', id)
|
||||||
.where('shared_link.userId', '=', userId)
|
.where('shared_link.userId', '=', userId)
|
||||||
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
|
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
|
||||||
@@ -134,9 +139,7 @@ export class SharedLinkRepository {
|
|||||||
.selectAll('asset')
|
.selectAll('asset')
|
||||||
.orderBy('asset.fileCreatedAt', 'asc')
|
.orderBy('asset.fileCreatedAt', 'asc')
|
||||||
.limit(1),
|
.limit(1),
|
||||||
)
|
).as('assets'),
|
||||||
.$castTo<MapAsset[]>()
|
|
||||||
.as('assets'),
|
|
||||||
)
|
)
|
||||||
.leftJoinLateral(
|
.leftJoinLateral(
|
||||||
(eb) =>
|
(eb) =>
|
||||||
@@ -175,7 +178,7 @@ export class SharedLinkRepository {
|
|||||||
.as('album'),
|
.as('album'),
|
||||||
(join) => join.onTrue(),
|
(join) => join.onTrue(),
|
||||||
)
|
)
|
||||||
.select((eb) => eb.fn.toJson('album').$castTo<Album | null>().as('album'))
|
.select((eb) => eb.fn.toJson('album').$castTo<ShallowDehydrateObject<Album> | null>().as('album'))
|
||||||
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
|
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
|
||||||
.$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!))
|
.$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!))
|
||||||
.$if(!!id, (eb) => eb.where('shared_link.id', '=', id!))
|
.$if(!!id, (eb) => eb.where('shared_link.id', '=', id!))
|
||||||
@@ -246,6 +249,7 @@ export class SharedLinkRepository {
|
|||||||
await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute();
|
await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
private getSharedLinks(id: string) {
|
private getSharedLinks(id: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('shared_link')
|
.selectFrom('shared_link')
|
||||||
@@ -269,7 +273,11 @@ export class SharedLinkRepository {
|
|||||||
.select((eb) =>
|
.select((eb) =>
|
||||||
eb.fn
|
eb.fn
|
||||||
.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`)
|
.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`)
|
||||||
.$castTo<MapAsset[]>()
|
.$castTo<
|
||||||
|
(ShallowDehydrateObject<Selectable<AssetTable>> & {
|
||||||
|
exifInfo: ShallowDehydrateObject<Selectable<AssetExifTable>>;
|
||||||
|
})[]
|
||||||
|
>()
|
||||||
.as('assets'),
|
.as('assets'),
|
||||||
)
|
)
|
||||||
.groupBy('shared_link.id')
|
.groupBy('shared_link.id')
|
||||||
|
|||||||
@@ -50,8 +50,18 @@ export class StorageRepository {
|
|||||||
return fs.readdir(folder);
|
return fs.readdir(folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
copyFile(source: string, target: string) {
|
async copyFile(source: string, target: string) {
|
||||||
return fs.copyFile(source, target);
|
await fs.copyFile(source, target);
|
||||||
|
await this.datasync(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async datasync(filepath: string) {
|
||||||
|
const handle = await fs.open(filepath, 'r');
|
||||||
|
try {
|
||||||
|
await handle.datasync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stat(filepath: string) {
|
stat(filepath: string) {
|
||||||
@@ -59,19 +69,19 @@ export class StorageRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createFile(filepath: string, buffer: Buffer) {
|
createFile(filepath: string, buffer: Buffer) {
|
||||||
return fs.writeFile(filepath, buffer, { flag: 'wx' });
|
return fs.writeFile(filepath, buffer, { flag: 'wx', flush: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
createWriteStream(filepath: string): Writable {
|
createWriteStream(filepath: string): Writable {
|
||||||
return createWriteStream(filepath, { flags: 'w' });
|
return createWriteStream(filepath, { flags: 'w', flush: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
createOrOverwriteFile(filepath: string, buffer: Buffer) {
|
createOrOverwriteFile(filepath: string, buffer: Buffer) {
|
||||||
return fs.writeFile(filepath, buffer, { flag: 'w' });
|
return fs.writeFile(filepath, buffer, { flag: 'w', flush: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
overwriteFile(filepath: string, buffer: Buffer) {
|
overwriteFile(filepath: string, buffer: Buffer) {
|
||||||
return fs.writeFile(filepath, buffer, { flag: 'r+' });
|
return fs.writeFile(filepath, buffer, { flag: 'r+', flush: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
rename(source: string, target: string) {
|
rename(source: string, target: string) {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
UPDATE system_metadata
|
||||||
|
SET value = jsonb_set(
|
||||||
|
value,
|
||||||
|
'{ffmpeg,acceptedAudioCodecs}',
|
||||||
|
(
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
CASE
|
||||||
|
WHEN elem = 'libopus' THEN 'opus'
|
||||||
|
ELSE elem
|
||||||
|
END
|
||||||
|
)
|
||||||
|
FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE key = 'system-config'
|
||||||
|
AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'libopus';
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE system_metadata
|
||||||
|
SET value = jsonb_set(
|
||||||
|
value,
|
||||||
|
'{ffmpeg,targetAudioCodec}',
|
||||||
|
'"opus"'::jsonb
|
||||||
|
)
|
||||||
|
WHERE key = 'system-config'
|
||||||
|
AND value->'ffmpeg'->>'targetAudioCodec' = 'libopus';
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
UPDATE system_metadata
|
||||||
|
SET value = jsonb_set(
|
||||||
|
value,
|
||||||
|
'{ffmpeg,acceptedAudioCodecs}',
|
||||||
|
(
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
CASE
|
||||||
|
WHEN elem = 'opus' THEN 'libopus'
|
||||||
|
ELSE elem
|
||||||
|
END
|
||||||
|
)
|
||||||
|
FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE key = 'system-config'
|
||||||
|
AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'opus';
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE system_metadata
|
||||||
|
SET value = jsonb_set(
|
||||||
|
value,
|
||||||
|
'{ffmpeg,targetAudioCodec}',
|
||||||
|
'"libopus"'::jsonb
|
||||||
|
)
|
||||||
|
WHERE key = 'system-config'
|
||||||
|
AND value->'ffmpeg'->>'targetAudioCodec' = 'opus';
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { ReactionType } from 'src/dtos/activity.dto';
|
import { ReactionType } from 'src/dtos/activity.dto';
|
||||||
import { ActivityService } from 'src/services/activity.service';
|
import { ActivityService } from 'src/services/activity.service';
|
||||||
|
import { getForActivity } from 'test/mappers';
|
||||||
import { factory, newUuid, newUuids } from 'test/small.factory';
|
import { factory, newUuid, newUuids } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ describe(ActivityService.name, () => {
|
|||||||
const activity = factory.activity({ albumId, assetId, userId });
|
const activity = factory.activity({ albumId, assetId, userId });
|
||||||
|
|
||||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.create.mockResolvedValue(activity);
|
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
||||||
|
|
||||||
await sut.create(factory.auth({ user: { id: userId } }), {
|
await sut.create(factory.auth({ user: { id: userId } }), {
|
||||||
albumId,
|
albumId,
|
||||||
@@ -101,7 +102,7 @@ describe(ActivityService.name, () => {
|
|||||||
const activity = factory.activity({ albumId, assetId });
|
const activity = factory.activity({ albumId, assetId });
|
||||||
|
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.create.mockResolvedValue(activity);
|
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
||||||
@@ -113,7 +114,7 @@ describe(ActivityService.name, () => {
|
|||||||
const activity = factory.activity({ userId, albumId, assetId, isLiked: true });
|
const activity = factory.activity({ userId, albumId, assetId, isLiked: true });
|
||||||
|
|
||||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.create.mockResolvedValue(activity);
|
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
||||||
mocks.activity.search.mockResolvedValue([]);
|
mocks.activity.search.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE });
|
await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE });
|
||||||
@@ -127,7 +128,7 @@ describe(ActivityService.name, () => {
|
|||||||
|
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.search.mockResolvedValue([activity]);
|
mocks.activity.search.mockResolvedValue([getForActivity(activity)]);
|
||||||
|
|
||||||
await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE });
|
await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE });
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import _ from 'lodash';
|
|
||||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { AlbumUserRole, AssetOrder, UserMetadataKey } from 'src/enum';
|
import { AlbumUserRole, AssetOrder, UserMetadataKey } from 'src/enum';
|
||||||
import { AlbumService } from 'src/services/album.service';
|
import { AlbumService } from 'src/services/album.service';
|
||||||
@@ -9,6 +8,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
|
|||||||
import { AuthFactory } from 'test/factories/auth.factory';
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
import { UserFactory } from 'test/factories/user.factory';
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
|
import { getForAlbum } from 'test/mappers';
|
||||||
import { newUuid } from 'test/small.factory';
|
import { newUuid } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ describe(AlbumService.name, () => {
|
|||||||
it('gets list of albums for auth user', async () => {
|
it('gets list of albums for auth user', async () => {
|
||||||
const album = AlbumFactory.from().albumUser().build();
|
const album = AlbumFactory.from().albumUser().build();
|
||||||
const sharedWithUserAlbum = AlbumFactory.from().owner(album.owner).albumUser().build();
|
const sharedWithUserAlbum = AlbumFactory.from().owner(album.owner).albumUser().build();
|
||||||
mocks.album.getOwned.mockResolvedValue([album, sharedWithUserAlbum]);
|
mocks.album.getOwned.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]);
|
||||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||||
{
|
{
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
@@ -70,8 +70,13 @@ describe(AlbumService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('gets list of albums that have a specific asset', async () => {
|
it('gets list of albums that have a specific asset', async () => {
|
||||||
const album = AlbumFactory.from().owner({ isAdmin: true }).albumUser().asset().asset().build();
|
const album = AlbumFactory.from()
|
||||||
mocks.album.getByAssetId.mockResolvedValue([album]);
|
.owner({ isAdmin: true })
|
||||||
|
.albumUser()
|
||||||
|
.asset({}, (builder) => builder.exif())
|
||||||
|
.asset({}, (builder) => builder.exif())
|
||||||
|
.build();
|
||||||
|
mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]);
|
||||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||||
{
|
{
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
@@ -90,7 +95,7 @@ describe(AlbumService.name, () => {
|
|||||||
|
|
||||||
it('gets list of albums that are shared', async () => {
|
it('gets list of albums that are shared', async () => {
|
||||||
const album = AlbumFactory.from().albumUser().build();
|
const album = AlbumFactory.from().albumUser().build();
|
||||||
mocks.album.getShared.mockResolvedValue([album]);
|
mocks.album.getShared.mockResolvedValue([getForAlbum(album)]);
|
||||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||||
{
|
{
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
@@ -109,7 +114,7 @@ describe(AlbumService.name, () => {
|
|||||||
|
|
||||||
it('gets list of albums that are NOT shared', async () => {
|
it('gets list of albums that are NOT shared', async () => {
|
||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
mocks.album.getNotShared.mockResolvedValue([album]);
|
mocks.album.getNotShared.mockResolvedValue([getForAlbum(album)]);
|
||||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||||
{
|
{
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
@@ -129,7 +134,7 @@ describe(AlbumService.name, () => {
|
|||||||
|
|
||||||
it('counts assets correctly', async () => {
|
it('counts assets correctly', async () => {
|
||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
mocks.album.getOwned.mockResolvedValue([album]);
|
mocks.album.getOwned.mockResolvedValue([getForAlbum(album)]);
|
||||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||||
{
|
{
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
@@ -155,7 +160,7 @@ describe(AlbumService.name, () => {
|
|||||||
.albumUser(albumUser)
|
.albumUser(albumUser)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
mocks.album.create.mockResolvedValue(album);
|
mocks.album.create.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.user.get.mockResolvedValue(UserFactory.create(album.albumUsers[0].user));
|
mocks.user.get.mockResolvedValue(UserFactory.create(album.albumUsers[0].user));
|
||||||
mocks.user.getMetadata.mockResolvedValue([]);
|
mocks.user.getMetadata.mockResolvedValue([]);
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||||
@@ -192,7 +197,7 @@ describe(AlbumService.name, () => {
|
|||||||
.asset({ id: assetId }, (asset) => asset.exif())
|
.asset({ id: assetId }, (asset) => asset.exif())
|
||||||
.albumUser(albumUser)
|
.albumUser(albumUser)
|
||||||
.build();
|
.build();
|
||||||
mocks.album.create.mockResolvedValue(album);
|
mocks.album.create.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.user.get.mockResolvedValue(album.albumUsers[0].user);
|
mocks.user.get.mockResolvedValue(album.albumUsers[0].user);
|
||||||
mocks.user.getMetadata.mockResolvedValue([
|
mocks.user.getMetadata.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -250,7 +255,7 @@ describe(AlbumService.name, () => {
|
|||||||
.albumUser()
|
.albumUser()
|
||||||
.build();
|
.build();
|
||||||
mocks.user.get.mockResolvedValue(album.albumUsers[0].user);
|
mocks.user.get.mockResolvedValue(album.albumUsers[0].user);
|
||||||
mocks.album.create.mockResolvedValue(album);
|
mocks.album.create.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.user.getMetadata.mockResolvedValue([]);
|
mocks.user.getMetadata.mockResolvedValue([]);
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||||
|
|
||||||
@@ -316,7 +321,7 @@ describe(AlbumService.name, () => {
|
|||||||
it('should require a valid thumbnail asset id', async () => {
|
it('should require a valid thumbnail asset id', async () => {
|
||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.getAssetIds.mockResolvedValue(new Set());
|
mocks.album.getAssetIds.mockResolvedValue(new Set());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -330,8 +335,8 @@ describe(AlbumService.name, () => {
|
|||||||
it('should allow the owner to update the album', async () => {
|
it('should allow the owner to update the album', async () => {
|
||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.update.mockResolvedValue(album);
|
mocks.album.update.mockResolvedValue(getForAlbum(album));
|
||||||
|
|
||||||
await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' });
|
await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' });
|
||||||
|
|
||||||
@@ -352,7 +357,7 @@ describe(AlbumService.name, () => {
|
|||||||
|
|
||||||
it('should not let a shared user delete the album', async () => {
|
it('should not let a shared user delete the album', async () => {
|
||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
|
||||||
|
|
||||||
await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
@@ -363,7 +368,7 @@ describe(AlbumService.name, () => {
|
|||||||
it('should let the owner delete an album', async () => {
|
it('should let the owner delete an album', async () => {
|
||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
|
|
||||||
await sut.delete(AuthFactory.create(album.owner), album.id);
|
await sut.delete(AuthFactory.create(album.owner), album.id);
|
||||||
|
|
||||||
@@ -387,7 +392,7 @@ describe(AlbumService.name, () => {
|
|||||||
const userId = newUuid();
|
const userId = newUuid();
|
||||||
const album = AlbumFactory.from().albumUser({ userId }).build();
|
const album = AlbumFactory.from().albumUser({ userId }).build();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
await expect(
|
await expect(
|
||||||
sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId }] }),
|
sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId }] }),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
@@ -398,7 +403,7 @@ describe(AlbumService.name, () => {
|
|||||||
it('should throw an error if the userId does not exist', async () => {
|
it('should throw an error if the userId does not exist', async () => {
|
||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.user.get.mockResolvedValue(void 0);
|
mocks.user.get.mockResolvedValue(void 0);
|
||||||
await expect(
|
await expect(
|
||||||
sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: 'unknown-user' }] }),
|
sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: 'unknown-user' }] }),
|
||||||
@@ -410,7 +415,7 @@ describe(AlbumService.name, () => {
|
|||||||
it('should throw an error if the userId is the ownerId', async () => {
|
it('should throw an error if the userId is the ownerId', async () => {
|
||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
await expect(
|
await expect(
|
||||||
sut.addUsers(AuthFactory.create(album.owner), album.id, {
|
sut.addUsers(AuthFactory.create(album.owner), album.id, {
|
||||||
albumUsers: [{ userId: album.owner.id }],
|
albumUsers: [{ userId: album.owner.id }],
|
||||||
@@ -424,8 +429,8 @@ describe(AlbumService.name, () => {
|
|||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
const user = UserFactory.create();
|
const user = UserFactory.create();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.update.mockResolvedValue(album);
|
mocks.album.update.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.user.get.mockResolvedValue(user);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build());
|
mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build());
|
||||||
|
|
||||||
@@ -456,7 +461,7 @@ describe(AlbumService.name, () => {
|
|||||||
const userId = newUuid();
|
const userId = newUuid();
|
||||||
const album = AlbumFactory.from().albumUser({ userId }).build();
|
const album = AlbumFactory.from().albumUser({ userId }).build();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.albumUser.delete.mockResolvedValue();
|
mocks.albumUser.delete.mockResolvedValue();
|
||||||
|
|
||||||
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, userId)).resolves.toBeUndefined();
|
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, userId)).resolves.toBeUndefined();
|
||||||
@@ -470,7 +475,7 @@ describe(AlbumService.name, () => {
|
|||||||
const user1 = UserFactory.create();
|
const user1 = UserFactory.create();
|
||||||
const user2 = UserFactory.create();
|
const user2 = UserFactory.create();
|
||||||
const album = AlbumFactory.from().albumUser({ userId: user1.id }).albumUser({ userId: user2.id }).build();
|
const album = AlbumFactory.from().albumUser({ userId: user1.id }).albumUser({ userId: user2.id }).build();
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
|
|
||||||
await expect(sut.removeUser(AuthFactory.create(user1), album.id, user2.id)).rejects.toBeInstanceOf(
|
await expect(sut.removeUser(AuthFactory.create(user1), album.id, user2.id)).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -483,7 +488,7 @@ describe(AlbumService.name, () => {
|
|||||||
it('should allow a shared user to remove themselves', async () => {
|
it('should allow a shared user to remove themselves', async () => {
|
||||||
const user1 = UserFactory.create();
|
const user1 = UserFactory.create();
|
||||||
const album = AlbumFactory.from().albumUser({ userId: user1.id }).build();
|
const album = AlbumFactory.from().albumUser({ userId: user1.id }).build();
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.albumUser.delete.mockResolvedValue();
|
mocks.albumUser.delete.mockResolvedValue();
|
||||||
|
|
||||||
await sut.removeUser(AuthFactory.create(user1), album.id, user1.id);
|
await sut.removeUser(AuthFactory.create(user1), album.id, user1.id);
|
||||||
@@ -495,7 +500,7 @@ describe(AlbumService.name, () => {
|
|||||||
it('should allow a shared user to remove themselves using "me"', async () => {
|
it('should allow a shared user to remove themselves using "me"', async () => {
|
||||||
const user = UserFactory.create();
|
const user = UserFactory.create();
|
||||||
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
|
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.albumUser.delete.mockResolvedValue();
|
mocks.albumUser.delete.mockResolvedValue();
|
||||||
|
|
||||||
await sut.removeUser(AuthFactory.create(user), album.id, 'me');
|
await sut.removeUser(AuthFactory.create(user), album.id, 'me');
|
||||||
@@ -506,7 +511,7 @@ describe(AlbumService.name, () => {
|
|||||||
|
|
||||||
it('should not allow the owner to be removed', async () => {
|
it('should not allow the owner to be removed', async () => {
|
||||||
const album = AlbumFactory.from().albumUser().build();
|
const album = AlbumFactory.from().albumUser().build();
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
|
|
||||||
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, album.owner.id)).rejects.toBeInstanceOf(
|
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, album.owner.id)).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -517,7 +522,7 @@ describe(AlbumService.name, () => {
|
|||||||
|
|
||||||
it('should throw an error for a user not in the album', async () => {
|
it('should throw an error for a user not in the album', async () => {
|
||||||
const album = AlbumFactory.from().albumUser().build();
|
const album = AlbumFactory.from().albumUser().build();
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
|
|
||||||
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, 'user-3')).rejects.toBeInstanceOf(
|
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, 'user-3')).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -546,7 +551,7 @@ describe(AlbumService.name, () => {
|
|||||||
describe('getAlbumInfo', () => {
|
describe('getAlbumInfo', () => {
|
||||||
it('should get a shared album', async () => {
|
it('should get a shared album', async () => {
|
||||||
const album = AlbumFactory.from().albumUser().build();
|
const album = AlbumFactory.from().albumUser().build();
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -566,7 +571,7 @@ describe(AlbumService.name, () => {
|
|||||||
|
|
||||||
it('should get a shared album via a shared link', async () => {
|
it('should get a shared album via a shared link', async () => {
|
||||||
const album = AlbumFactory.from().albumUser().build();
|
const album = AlbumFactory.from().albumUser().build();
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -588,7 +593,7 @@ describe(AlbumService.name, () => {
|
|||||||
it('should get a shared album via shared with user', async () => {
|
it('should get a shared album via shared with user', async () => {
|
||||||
const user = UserFactory.create();
|
const user = UserFactory.create();
|
||||||
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
|
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -630,7 +635,7 @@ describe(AlbumService.name, () => {
|
|||||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -654,7 +659,7 @@ describe(AlbumService.name, () => {
|
|||||||
const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build();
|
const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset2.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset2.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset2.id] })).resolves.toEqual([
|
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset2.id] })).resolves.toEqual([
|
||||||
@@ -675,7 +680,7 @@ describe(AlbumService.name, () => {
|
|||||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -703,7 +708,7 @@ describe(AlbumService.name, () => {
|
|||||||
const album = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Viewer }).build();
|
const album = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Viewer }).build();
|
||||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set());
|
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set());
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset1.id, asset2.id, asset3.id] }),
|
sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset1.id, asset2.id, asset3.id] }),
|
||||||
@@ -718,7 +723,7 @@ describe(AlbumService.name, () => {
|
|||||||
const auth = AuthFactory.from(album.owner).sharedLink({ allowUpload: true, userId: album.ownerId }).build();
|
const auth = AuthFactory.from(album.owner).sharedLink({ allowUpload: true, userId: album.ownerId }).build();
|
||||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
await expect(sut.addAssets(auth, album.id, { ids: [asset1.id, asset2.id, asset3.id] })).resolves.toEqual([
|
await expect(sut.addAssets(auth, album.id, { ids: [asset1.id, asset2.id, asset3.id] })).resolves.toEqual([
|
||||||
@@ -742,7 +747,7 @@ describe(AlbumService.name, () => {
|
|||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
||||||
@@ -762,7 +767,7 @@ describe(AlbumService.name, () => {
|
|||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set([asset.id]));
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set([asset.id]));
|
||||||
|
|
||||||
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
||||||
@@ -776,7 +781,7 @@ describe(AlbumService.name, () => {
|
|||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
||||||
@@ -791,7 +796,7 @@ describe(AlbumService.name, () => {
|
|||||||
const user = UserFactory.create();
|
const user = UserFactory.create();
|
||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
const asset = AssetFactory.create({ ownerId: user.id });
|
const asset = AssetFactory.create({ ownerId: user.id });
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
|
|
||||||
await expect(sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset.id] })).rejects.toBeInstanceOf(
|
await expect(sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset.id] })).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -804,7 +809,7 @@ describe(AlbumService.name, () => {
|
|||||||
it('should not allow unauthorized shared link access to the album', async () => {
|
it('should not allow unauthorized shared link access to the album', async () => {
|
||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.addAssets(AuthFactory.from().sharedLink({ allowUpload: true }).build(), album.id, { ids: [asset.id] }),
|
sut.addAssets(AuthFactory.from().sharedLink({ allowUpload: true }).build(), album.id, { ids: [asset.id] }),
|
||||||
@@ -821,7 +826,7 @@ describe(AlbumService.name, () => {
|
|||||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -859,7 +864,7 @@ describe(AlbumService.name, () => {
|
|||||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -897,7 +902,7 @@ describe(AlbumService.name, () => {
|
|||||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
|
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -943,7 +948,7 @@ describe(AlbumService.name, () => {
|
|||||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -965,7 +970,7 @@ describe(AlbumService.name, () => {
|
|||||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValueOnce(new Set([album1.id]));
|
mocks.access.album.checkSharedLinkAccess.mockResolvedValueOnce(new Set([album1.id]));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
const auth = AuthFactory.from(album1.owner).sharedLink({ allowUpload: true }).build();
|
const auth = AuthFactory.from(album1.owner).sharedLink({ allowUpload: true }).build();
|
||||||
@@ -1004,7 +1009,7 @@ describe(AlbumService.name, () => {
|
|||||||
];
|
];
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
|
||||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -1048,7 +1053,7 @@ describe(AlbumService.name, () => {
|
|||||||
mocks.album.getAssetIds
|
mocks.album.getAssetIds
|
||||||
.mockResolvedValueOnce(new Set([asset1.id, asset2.id, asset3.id]))
|
.mockResolvedValueOnce(new Set([asset1.id, asset2.id, asset3.id]))
|
||||||
.mockResolvedValueOnce(new Set());
|
.mockResolvedValueOnce(new Set());
|
||||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
|
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
|
||||||
@@ -1078,7 +1083,7 @@ describe(AlbumService.name, () => {
|
|||||||
.mockResolvedValueOnce(new Set([album1.id]))
|
.mockResolvedValueOnce(new Set([album1.id]))
|
||||||
.mockResolvedValueOnce(new Set([album2.id]));
|
.mockResolvedValueOnce(new Set([album2.id]));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||||
mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -1107,7 +1112,7 @@ describe(AlbumService.name, () => {
|
|||||||
mocks.access.album.checkSharedAlbumAccess
|
mocks.access.album.checkSharedAlbumAccess
|
||||||
.mockResolvedValueOnce(new Set([album1.id]))
|
.mockResolvedValueOnce(new Set([album1.id]))
|
||||||
.mockResolvedValueOnce(new Set([album2.id]));
|
.mockResolvedValueOnce(new Set([album2.id]));
|
||||||
mocks.album.getById.mockResolvedValueOnce(_.cloneDeep(album1)).mockResolvedValueOnce(_.cloneDeep(album2));
|
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -1138,7 +1143,7 @@ describe(AlbumService.name, () => {
|
|||||||
const album1 = AlbumFactory.create();
|
const album1 = AlbumFactory.create();
|
||||||
const album2 = AlbumFactory.create();
|
const album2 = AlbumFactory.create();
|
||||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.addAssetsToAlbums(AuthFactory.create(user), {
|
sut.addAssetsToAlbums(AuthFactory.create(user), {
|
||||||
@@ -1160,7 +1165,7 @@ describe(AlbumService.name, () => {
|
|||||||
const album1 = AlbumFactory.create();
|
const album1 = AlbumFactory.create();
|
||||||
const album2 = AlbumFactory.create();
|
const album2 = AlbumFactory.create();
|
||||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.addAssetsToAlbums(AuthFactory.from().sharedLink({ allowUpload: true }).build(), {
|
sut.addAssetsToAlbums(AuthFactory.from().sharedLink({ allowUpload: true }).build(), {
|
||||||
@@ -1182,7 +1187,7 @@ describe(AlbumService.name, () => {
|
|||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id]));
|
mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id]));
|
||||||
|
|
||||||
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
||||||
@@ -1196,7 +1201,7 @@ describe(AlbumService.name, () => {
|
|||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.getAssetIds.mockResolvedValue(new Set());
|
mocks.album.getAssetIds.mockResolvedValue(new Set());
|
||||||
|
|
||||||
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
||||||
@@ -1210,7 +1215,7 @@ describe(AlbumService.name, () => {
|
|||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
const album = AlbumFactory.create();
|
const album = AlbumFactory.create();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id]));
|
mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id]));
|
||||||
|
|
||||||
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
||||||
@@ -1224,7 +1229,7 @@ describe(AlbumService.name, () => {
|
|||||||
const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build();
|
const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build();
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id]));
|
||||||
mocks.album.getById.mockResolvedValue(album);
|
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||||
mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id]));
|
mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id]));
|
||||||
|
|
||||||
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset1.id] })).resolves.toEqual([
|
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset1.id] })).resolves.toEqual([
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Permission } from 'src/enum';
|
|||||||
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
|
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
||||||
|
import { asDateString } from 'src/utils/date';
|
||||||
import { getPreferences } from 'src/utils/preferences';
|
import { getPreferences } from 'src/utils/preferences';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -64,11 +65,11 @@ export class AlbumService extends BaseService {
|
|||||||
return albums.map((album) => ({
|
return albums.map((album) => ({
|
||||||
...mapAlbumWithoutAssets(album),
|
...mapAlbumWithoutAssets(album),
|
||||||
sharedLinks: undefined,
|
sharedLinks: undefined,
|
||||||
startDate: albumMetadata[album.id]?.startDate ?? undefined,
|
startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined),
|
||||||
endDate: albumMetadata[album.id]?.endDate ?? undefined,
|
endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined),
|
||||||
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
|
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
|
||||||
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
|
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
|
||||||
lastModifiedAssetTimestamp: albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined,
|
lastModifiedAssetTimestamp: asDateString(albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,10 +86,10 @@ export class AlbumService extends BaseService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...mapAlbum(album, withAssets, auth),
|
...mapAlbum(album, withAssets, auth),
|
||||||
startDate: albumMetadataForIds?.startDate ?? undefined,
|
startDate: asDateString(albumMetadataForIds?.startDate ?? undefined),
|
||||||
endDate: albumMetadataForIds?.endDate ?? undefined,
|
endDate: asDateString(albumMetadataForIds?.endDate ?? undefined),
|
||||||
assetCount: albumMetadataForIds?.assetCount ?? 0,
|
assetCount: albumMetadataForIds?.assetCount ?? 0,
|
||||||
lastModifiedAssetTimestamp: albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined,
|
lastModifiedAssetTimestamp: asDateString(albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined),
|
||||||
contributorCounts: isShared ? await this.albumRepository.getContributorCounts(album.id) : undefined,
|
contributorCounts: isShared ? await this.albumRepository.getContributorCounts(album.id) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Stats } from 'node:fs';
|
|
||||||
import { AssetFile } from 'src/database';
|
import { AssetFile } from 'src/database';
|
||||||
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
|
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
|
||||||
import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
|
import { AssetMediaCreateDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||||
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
|
import { AssetFileType, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
|
||||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||||
import { UploadBody } from 'src/types';
|
import { UploadBody } from 'src/types';
|
||||||
@@ -22,6 +21,7 @@ import { AuthFactory } from 'test/factories/auth.factory';
|
|||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { fileStub } from 'test/fixtures/file.stub';
|
import { fileStub } from 'test/fixtures/file.stub';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
|
import { getForAsset } from 'test/mappers';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||||
@@ -152,13 +152,6 @@ const createDto = Object.freeze({
|
|||||||
duration: '0:00:00.000000',
|
duration: '0:00:00.000000',
|
||||||
}) as AssetMediaCreateDto;
|
}) as AssetMediaCreateDto;
|
||||||
|
|
||||||
const replaceDto = Object.freeze({
|
|
||||||
deviceAssetId: 'deviceAssetId',
|
|
||||||
deviceId: 'deviceId',
|
|
||||||
fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'),
|
|
||||||
fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'),
|
|
||||||
}) as AssetMediaReplaceDto;
|
|
||||||
|
|
||||||
const assetEntity = Object.freeze({
|
const assetEntity = Object.freeze({
|
||||||
id: 'id_1',
|
id: 'id_1',
|
||||||
ownerId: 'user_id_1',
|
ownerId: 'user_id_1',
|
||||||
@@ -180,25 +173,6 @@ const assetEntity = Object.freeze({
|
|||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
} as MapAsset);
|
} as MapAsset);
|
||||||
|
|
||||||
const existingAsset = Object.freeze({
|
|
||||||
...assetEntity,
|
|
||||||
duration: null,
|
|
||||||
type: AssetType.Image,
|
|
||||||
checksum: Buffer.from('_getExistingAsset', 'utf8'),
|
|
||||||
libraryId: 'libraryId',
|
|
||||||
originalFileName: 'existing-filename.jpeg',
|
|
||||||
}) as MapAsset;
|
|
||||||
|
|
||||||
const sidecarAsset = Object.freeze({
|
|
||||||
...existingAsset,
|
|
||||||
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
|
|
||||||
}) as MapAsset;
|
|
||||||
|
|
||||||
const copiedAsset = Object.freeze({
|
|
||||||
id: 'copied-asset',
|
|
||||||
originalPath: 'copied-path',
|
|
||||||
}) as MapAsset;
|
|
||||||
|
|
||||||
describe(AssetMediaService.name, () => {
|
describe(AssetMediaService.name, () => {
|
||||||
let sut: AssetMediaService;
|
let sut: AssetMediaService;
|
||||||
let mocks: ServiceMocks;
|
let mocks: ServiceMocks;
|
||||||
@@ -434,7 +408,7 @@ describe(AssetMediaService.name, () => {
|
|||||||
.owner(authStub.user1.user)
|
.owner(authStub.user1.user)
|
||||||
.build();
|
.build();
|
||||||
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
||||||
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
|
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
|
||||||
mocks.asset.create.mockResolvedValueOnce(asset);
|
mocks.asset.create.mockResolvedValueOnce(asset);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -451,7 +425,7 @@ describe(AssetMediaService.name, () => {
|
|||||||
it('should hide the linked motion asset', async () => {
|
it('should hide the linked motion asset', async () => {
|
||||||
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
|
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
|
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
|
||||||
mocks.asset.create.mockResolvedValueOnce(asset);
|
mocks.asset.create.mockResolvedValueOnce(asset);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -470,7 +444,7 @@ describe(AssetMediaService.name, () => {
|
|||||||
|
|
||||||
it('should handle a sidecar file', async () => {
|
it('should handle a sidecar file', async () => {
|
||||||
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build();
|
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build();
|
||||||
mocks.asset.getById.mockResolvedValueOnce(asset);
|
mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset));
|
||||||
mocks.asset.create.mockResolvedValueOnce(asset);
|
mocks.asset.create.mockResolvedValueOnce(asset);
|
||||||
|
|
||||||
await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({
|
await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({
|
||||||
@@ -776,177 +750,6 @@ describe(AssetMediaService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('replaceAsset', () => {
|
|
||||||
it('should fail the auth check when update photo does not exist', async () => {
|
|
||||||
await expect(sut.replaceAsset(authStub.user1, 'id', replaceDto, fileStub.photo)).rejects.toThrow(
|
|
||||||
'Not found or no asset.update access',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fail if asset cannot be fetched', async () => {
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
|
||||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, fileStub.photo)).rejects.toThrow(
|
|
||||||
'Asset not found',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update a photo with no sidecar to photo with no sidecar', async () => {
|
|
||||||
const updatedFile = fileStub.photo;
|
|
||||||
const updatedAsset = { ...existingAsset, ...updatedFile };
|
|
||||||
mocks.asset.getById.mockResolvedValueOnce(existingAsset);
|
|
||||||
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
|
||||||
// this is the original file size
|
|
||||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
|
||||||
// this is for the clone call
|
|
||||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
|
||||||
|
|
||||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
|
||||||
status: AssetMediaStatus.REPLACED,
|
|
||||||
id: 'copied-asset',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: existingAsset.id,
|
|
||||||
originalFileName: 'photo1.jpeg',
|
|
||||||
originalPath: 'fake_path/photo1.jpeg',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(mocks.asset.create).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
originalFileName: 'existing-filename.jpeg',
|
|
||||||
originalPath: 'fake_path/asset_1.jpeg',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
assetId: existingAsset.id,
|
|
||||||
type: AssetFileType.Sidecar,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
|
||||||
deletedAt: expect.any(Date),
|
|
||||||
status: AssetStatus.Trashed,
|
|
||||||
});
|
|
||||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
|
||||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
|
||||||
updatedFile.originalPath,
|
|
||||||
expect.any(Date),
|
|
||||||
new Date(replaceDto.fileModifiedAt),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update a photo with sidecar to photo with sidecar', async () => {
|
|
||||||
const updatedFile = fileStub.photo;
|
|
||||||
const sidecarFile = fileStub.photoSidecar;
|
|
||||||
const updatedAsset = { ...sidecarAsset, ...updatedFile };
|
|
||||||
mocks.asset.getById.mockResolvedValueOnce(existingAsset);
|
|
||||||
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
|
||||||
// this is the original file size
|
|
||||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
|
||||||
// this is for the clone call
|
|
||||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile),
|
|
||||||
).resolves.toEqual({
|
|
||||||
status: AssetMediaStatus.REPLACED,
|
|
||||||
id: 'copied-asset',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
|
||||||
deletedAt: expect.any(Date),
|
|
||||||
status: AssetStatus.Trashed,
|
|
||||||
});
|
|
||||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
assetId: existingAsset.id,
|
|
||||||
path: sidecarFile.originalPath,
|
|
||||||
type: AssetFileType.Sidecar,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
|
||||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
|
||||||
updatedFile.originalPath,
|
|
||||||
expect.any(Date),
|
|
||||||
new Date(replaceDto.fileModifiedAt),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update a photo with a sidecar to photo with no sidecar', async () => {
|
|
||||||
const updatedFile = fileStub.photo;
|
|
||||||
|
|
||||||
const updatedAsset = { ...sidecarAsset, ...updatedFile };
|
|
||||||
mocks.asset.getById.mockResolvedValueOnce(sidecarAsset);
|
|
||||||
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
|
||||||
// this is the original file size
|
|
||||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
|
||||||
// this is for the copy call
|
|
||||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
|
||||||
|
|
||||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
|
||||||
status: AssetMediaStatus.REPLACED,
|
|
||||||
id: 'copied-asset',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
|
||||||
deletedAt: expect.any(Date),
|
|
||||||
status: AssetStatus.Trashed,
|
|
||||||
});
|
|
||||||
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
assetId: existingAsset.id,
|
|
||||||
type: AssetFileType.Sidecar,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
|
||||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
|
||||||
updatedFile.originalPath,
|
|
||||||
expect.any(Date),
|
|
||||||
new Date(replaceDto.fileModifiedAt),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle a photo with sidecar to duplicate photo ', async () => {
|
|
||||||
const updatedFile = fileStub.photo;
|
|
||||||
const error = new Error('unique key violation');
|
|
||||||
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
|
||||||
|
|
||||||
mocks.asset.update.mockRejectedValue(error);
|
|
||||||
mocks.asset.getById.mockResolvedValueOnce(sidecarAsset);
|
|
||||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id);
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
|
||||||
// this is the original file size
|
|
||||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
|
||||||
// this is for the clone call
|
|
||||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
|
||||||
|
|
||||||
await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
|
||||||
status: AssetMediaStatus.DUPLICATE,
|
|
||||||
id: sidecarAsset.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
|
||||||
name: JobName.FileDelete,
|
|
||||||
data: { files: [updatedFile.originalPath, undefined] },
|
|
||||||
});
|
|
||||||
expect(mocks.user.updateUsage).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('bulkUploadCheck', () => {
|
describe('bulkUploadCheck', () => {
|
||||||
it('should accept hex and base64 checksums', async () => {
|
it('should accept hex and base64 checksums', async () => {
|
||||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AssetService } from 'src/services/asset.service';
|
|||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { AuthFactory } from 'test/factories/auth.factory';
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
|
import { getForAsset, getForAssetDeletion, getForPartner } from 'test/mappers';
|
||||||
import { factory, newUuid } from 'test/small.factory';
|
import { factory, newUuid } from 'test/small.factory';
|
||||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ describe(AssetService.name, () => {
|
|||||||
describe('getRandom', () => {
|
describe('getRandom', () => {
|
||||||
it('should get own random assets', async () => {
|
it('should get own random assets', async () => {
|
||||||
mocks.partner.getAll.mockResolvedValue([]);
|
mocks.partner.getAll.mockResolvedValue([]);
|
||||||
mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]);
|
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
|
||||||
|
|
||||||
await sut.getRandom(authStub.admin, 1);
|
await sut.getRandom(authStub.admin, 1);
|
||||||
|
|
||||||
@@ -82,8 +83,8 @@ describe(AssetService.name, () => {
|
|||||||
const partner = factory.partner({ inTimeline: false });
|
const partner = factory.partner({ inTimeline: false });
|
||||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||||
|
|
||||||
mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]);
|
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
|
||||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||||
|
|
||||||
await sut.getRandom(auth, 1);
|
await sut.getRandom(auth, 1);
|
||||||
|
|
||||||
@@ -94,8 +95,8 @@ describe(AssetService.name, () => {
|
|||||||
const partner = factory.partner({ inTimeline: true });
|
const partner = factory.partner({ inTimeline: true });
|
||||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||||
|
|
||||||
mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]);
|
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
|
||||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||||
|
|
||||||
await sut.getRandom(auth, 1);
|
await sut.getRandom(auth, 1);
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ describe(AssetService.name, () => {
|
|||||||
it('should allow owner access', async () => {
|
it('should allow owner access', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getById.mockResolvedValue(asset);
|
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||||
|
|
||||||
await sut.get(authStub.admin, asset.id);
|
await sut.get(authStub.admin, asset.id);
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ describe(AssetService.name, () => {
|
|||||||
it('should allow shared link access', async () => {
|
it('should allow shared link access', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getById.mockResolvedValue(asset);
|
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||||
|
|
||||||
await sut.get(authStub.adminSharedLink, asset.id);
|
await sut.get(authStub.adminSharedLink, asset.id);
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ describe(AssetService.name, () => {
|
|||||||
it('should strip metadata for shared link if exif is disabled', async () => {
|
it('should strip metadata for shared link if exif is disabled', async () => {
|
||||||
const asset = AssetFactory.from().exif({ description: 'foo' }).build();
|
const asset = AssetFactory.from().exif({ description: 'foo' }).build();
|
||||||
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getById.mockResolvedValue(asset);
|
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||||
|
|
||||||
const result = await sut.get(
|
const result = await sut.get(
|
||||||
{ ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } },
|
{ ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } },
|
||||||
@@ -152,7 +153,7 @@ describe(AssetService.name, () => {
|
|||||||
it('should allow partner sharing access', async () => {
|
it('should allow partner sharing access', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getById.mockResolvedValue(asset);
|
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||||
|
|
||||||
await sut.get(authStub.admin, asset.id);
|
await sut.get(authStub.admin, asset.id);
|
||||||
|
|
||||||
@@ -162,7 +163,7 @@ describe(AssetService.name, () => {
|
|||||||
it('should allow shared album access', async () => {
|
it('should allow shared album access', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getById.mockResolvedValue(asset);
|
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||||
|
|
||||||
await sut.get(authStub.admin, asset.id);
|
await sut.get(authStub.admin, asset.id);
|
||||||
|
|
||||||
@@ -204,8 +205,8 @@ describe(AssetService.name, () => {
|
|||||||
it('should update the asset', async () => {
|
it('should update the asset', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getById.mockResolvedValue(asset);
|
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||||
mocks.asset.update.mockResolvedValue(asset);
|
mocks.asset.update.mockResolvedValue(getForAsset(asset));
|
||||||
|
|
||||||
await sut.update(authStub.admin, asset.id, { isFavorite: true });
|
await sut.update(authStub.admin, asset.id, { isFavorite: true });
|
||||||
|
|
||||||
@@ -215,8 +216,8 @@ describe(AssetService.name, () => {
|
|||||||
it('should update the exif description', async () => {
|
it('should update the exif description', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getById.mockResolvedValue(asset);
|
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||||
mocks.asset.update.mockResolvedValue(asset);
|
mocks.asset.update.mockResolvedValue(getForAsset(asset));
|
||||||
|
|
||||||
await sut.update(authStub.admin, asset.id, { description: 'Test description' });
|
await sut.update(authStub.admin, asset.id, { description: 'Test description' });
|
||||||
|
|
||||||
@@ -229,8 +230,8 @@ describe(AssetService.name, () => {
|
|||||||
it('should update the exif rating', async () => {
|
it('should update the exif rating', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getById.mockResolvedValueOnce(asset);
|
mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset));
|
||||||
mocks.asset.update.mockResolvedValueOnce(asset);
|
mocks.asset.update.mockResolvedValueOnce(getForAsset(asset));
|
||||||
|
|
||||||
await sut.update(authStub.admin, asset.id, { rating: 3 });
|
await sut.update(authStub.admin, asset.id, { rating: 3 });
|
||||||
|
|
||||||
@@ -274,7 +275,7 @@ describe(AssetService.name, () => {
|
|||||||
const motionAsset = AssetFactory.from().owner(auth.user).build();
|
const motionAsset = AssetFactory.from().owner(auth.user).build();
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getById.mockResolvedValue(asset);
|
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.update(authStub.admin, asset.id, {
|
sut.update(authStub.admin, asset.id, {
|
||||||
@@ -301,7 +302,7 @@ describe(AssetService.name, () => {
|
|||||||
const motionAsset = AssetFactory.create({ type: AssetType.Video });
|
const motionAsset = AssetFactory.create({ type: AssetType.Video });
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getById.mockResolvedValue(motionAsset);
|
mocks.asset.getById.mockResolvedValue(getForAsset(motionAsset));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.update(auth, asset.id, {
|
sut.update(auth, asset.id, {
|
||||||
@@ -327,9 +328,9 @@ describe(AssetService.name, () => {
|
|||||||
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Timeline });
|
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Timeline });
|
||||||
const stillAsset = AssetFactory.create();
|
const stillAsset = AssetFactory.create();
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([stillAsset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([stillAsset.id]));
|
||||||
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
|
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
|
||||||
mocks.asset.getById.mockResolvedValueOnce(stillAsset);
|
mocks.asset.getById.mockResolvedValueOnce(getForAsset(stillAsset));
|
||||||
mocks.asset.update.mockResolvedValue(stillAsset);
|
mocks.asset.update.mockResolvedValue(getForAsset(stillAsset));
|
||||||
const auth = AuthFactory.from(motionAsset.owner).build();
|
const auth = AuthFactory.from(motionAsset.owner).build();
|
||||||
|
|
||||||
await sut.update(auth, stillAsset.id, { livePhotoVideoId: motionAsset.id });
|
await sut.update(auth, stillAsset.id, { livePhotoVideoId: motionAsset.id });
|
||||||
@@ -354,9 +355,9 @@ describe(AssetService.name, () => {
|
|||||||
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
||||||
const unlinkedAsset = AssetFactory.create();
|
const unlinkedAsset = AssetFactory.create();
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getById.mockResolvedValueOnce(asset);
|
mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset));
|
||||||
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
|
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
|
||||||
mocks.asset.update.mockResolvedValueOnce(unlinkedAsset);
|
mocks.asset.update.mockResolvedValueOnce(getForAsset(unlinkedAsset));
|
||||||
|
|
||||||
await sut.update(auth, asset.id, { livePhotoVideoId: null });
|
await sut.update(auth, asset.id, { livePhotoVideoId: null });
|
||||||
|
|
||||||
@@ -532,7 +533,7 @@ describe(AssetService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should immediately queue assets for deletion if trash is disabled', async () => {
|
it('should immediately queue assets for deletion if trash is disabled', async () => {
|
||||||
const asset = factory.asset({ isOffline: false });
|
const asset = AssetFactory.create();
|
||||||
|
|
||||||
mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset]));
|
mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset]));
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } });
|
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } });
|
||||||
@@ -546,7 +547,7 @@ describe(AssetService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should queue assets for deletion after trash duration', async () => {
|
it('should queue assets for deletion after trash duration', async () => {
|
||||||
const asset = factory.asset({ isOffline: false });
|
const asset = AssetFactory.create();
|
||||||
|
|
||||||
mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset]));
|
mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset]));
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } });
|
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } });
|
||||||
@@ -569,7 +570,7 @@ describe(AssetService.name, () => {
|
|||||||
.file({ type: AssetFileType.Preview, isEdited: true })
|
.file({ type: AssetFileType.Preview, isEdited: true })
|
||||||
.file({ type: AssetFileType.Thumbnail, isEdited: true })
|
.file({ type: AssetFileType.Thumbnail, isEdited: true })
|
||||||
.build();
|
.build();
|
||||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
|
||||||
|
|
||||||
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||||
|
|
||||||
@@ -583,7 +584,7 @@ describe(AssetService.name, () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
expect(mocks.asset.remove).toHaveBeenCalledWith(asset);
|
expect(mocks.asset.remove).toHaveBeenCalledWith(getForAssetDeletion(asset));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
|
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
|
||||||
@@ -591,11 +592,7 @@ describe(AssetService.name, () => {
|
|||||||
.stack({}, (builder) => builder.asset())
|
.stack({}, (builder) => builder.asset())
|
||||||
.build();
|
.build();
|
||||||
mocks.stack.delete.mockResolvedValue();
|
mocks.stack.delete.mockResolvedValue();
|
||||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue({
|
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
|
||||||
...asset,
|
|
||||||
// TODO the specific query filters out the primary asset from `stack.assets`. This should be in a mapper eventually
|
|
||||||
stack: { ...asset.stack!, assets: asset.stack!.assets.filter(({ id }) => id !== asset.stack!.primaryAssetId) },
|
|
||||||
});
|
|
||||||
|
|
||||||
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||||
|
|
||||||
@@ -605,7 +602,7 @@ describe(AssetService.name, () => {
|
|||||||
it('should delete a live photo', async () => {
|
it('should delete a live photo', async () => {
|
||||||
const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build();
|
const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build();
|
||||||
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
||||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
|
||||||
mocks.asset.getLivePhotoCount.mockResolvedValue(0);
|
mocks.asset.getLivePhotoCount.mockResolvedValue(0);
|
||||||
|
|
||||||
await sut.handleAssetDeletion({
|
await sut.handleAssetDeletion({
|
||||||
@@ -622,7 +619,7 @@ describe(AssetService.name, () => {
|
|||||||
it('should not delete a live motion part if it is being used by another asset', async () => {
|
it('should not delete a live motion part if it is being used by another asset', async () => {
|
||||||
const asset = AssetFactory.create({ livePhotoVideoId: newUuid() });
|
const asset = AssetFactory.create({ livePhotoVideoId: newUuid() });
|
||||||
mocks.asset.getLivePhotoCount.mockResolvedValue(2);
|
mocks.asset.getLivePhotoCount.mockResolvedValue(2);
|
||||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
|
||||||
|
|
||||||
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||||
|
|
||||||
@@ -633,7 +630,7 @@ describe(AssetService.name, () => {
|
|||||||
|
|
||||||
it('should update usage', async () => {
|
it('should update usage', async () => {
|
||||||
const asset = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build();
|
const asset = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build();
|
||||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
|
||||||
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, -5000);
|
expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, -5000);
|
||||||
});
|
});
|
||||||
@@ -739,7 +736,7 @@ describe(AssetService.name, () => {
|
|||||||
|
|
||||||
describe('upsertMetadata', () => {
|
describe('upsertMetadata', () => {
|
||||||
it('should throw a bad request exception if duplicate keys are sent', async () => {
|
it('should throw a bad request exception if duplicate keys are sent', async () => {
|
||||||
const asset = factory.asset();
|
const asset = AssetFactory.create();
|
||||||
const items = [
|
const items = [
|
||||||
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||||
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||||
@@ -757,7 +754,7 @@ describe(AssetService.name, () => {
|
|||||||
|
|
||||||
describe('upsertBulkMetadata', () => {
|
describe('upsertBulkMetadata', () => {
|
||||||
it('should throw a bad request exception if duplicate keys are sent', async () => {
|
it('should throw a bad request exception if duplicate keys are sent', async () => {
|
||||||
const asset = factory.asset();
|
const asset = AssetFactory.create();
|
||||||
const items = [
|
const items = [
|
||||||
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||||
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DuplicateService } from 'src/services/duplicate.service';
|
|||||||
import { SearchService } from 'src/services/search.service';
|
import { SearchService } from 'src/services/search.service';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
|
import { getForDuplicate } from 'test/mappers';
|
||||||
import { newUuid } from 'test/small.factory';
|
import { newUuid } from 'test/small.factory';
|
||||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||||
import { beforeEach, vitest } from 'vitest';
|
import { beforeEach, vitest } from 'vitest';
|
||||||
@@ -39,11 +40,11 @@ describe(SearchService.name, () => {
|
|||||||
|
|
||||||
describe('getDuplicates', () => {
|
describe('getDuplicates', () => {
|
||||||
it('should get duplicates', async () => {
|
it('should get duplicates', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.from().exif().build();
|
||||||
mocks.duplicateRepository.getAll.mockResolvedValue([
|
mocks.duplicateRepository.getAll.mockResolvedValue([
|
||||||
{
|
{
|
||||||
duplicateId: 'duplicate-id',
|
duplicateId: 'duplicate-id',
|
||||||
assets: [asset, asset],
|
assets: [getForDuplicate(asset), getForDuplicate(asset)],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([
|
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([
|
||||||
|
|||||||
@@ -186,8 +186,8 @@ export class JobService extends BaseService {
|
|||||||
exifImageHeight: exif.exifImageHeight,
|
exifImageHeight: exif.exifImageHeight,
|
||||||
fileSizeInByte: exif.fileSizeInByte,
|
fileSizeInByte: exif.fileSizeInByte,
|
||||||
orientation: exif.orientation,
|
orientation: exif.orientation,
|
||||||
dateTimeOriginal: exif.dateTimeOriginal,
|
dateTimeOriginal: exif.dateTimeOriginal ? new Date(exif.dateTimeOriginal) : null,
|
||||||
modifyDate: exif.modifyDate,
|
modifyDate: exif.modifyDate ? new Date(exif.modifyDate) : null,
|
||||||
timeZone: exif.timeZone,
|
timeZone: exif.timeZone,
|
||||||
latitude: exif.latitude,
|
latitude: exif.latitude,
|
||||||
longitude: exif.longitude,
|
longitude: exif.longitude,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AlbumFactory } from 'test/factories/album.factory';
|
|||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { AuthFactory } from 'test/factories/auth.factory';
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
|
import { getForAlbum, getForPartner } from 'test/mappers';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ describe(MapService.name, () => {
|
|||||||
state: asset.exifInfo.state,
|
state: asset.exifInfo.state,
|
||||||
country: asset.exifInfo.country,
|
country: asset.exifInfo.country,
|
||||||
};
|
};
|
||||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||||
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
||||||
|
|
||||||
const markers = await sut.getMapMarkers(auth, { withPartners: true });
|
const markers = await sut.getMapMarkers(auth, { withPartners: true });
|
||||||
@@ -81,8 +82,10 @@ describe(MapService.name, () => {
|
|||||||
};
|
};
|
||||||
mocks.partner.getAll.mockResolvedValue([]);
|
mocks.partner.getAll.mockResolvedValue([]);
|
||||||
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
||||||
mocks.album.getOwned.mockResolvedValue([AlbumFactory.create()]);
|
mocks.album.getOwned.mockResolvedValue([getForAlbum(AlbumFactory.create())]);
|
||||||
mocks.album.getShared.mockResolvedValue([AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()]);
|
mocks.album.getShared.mockResolvedValue([
|
||||||
|
getForAlbum(AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()),
|
||||||
|
]);
|
||||||
|
|
||||||
const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true });
|
const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true });
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user