Compare commits

..

44 Commits

Author SHA1 Message Date
Jason Rasmussen
a5e88f41ea refactor!: remove replace asset 2026-03-18 15:14:50 -04:00
Jason Rasmussen
77020e742a fix: validate accept header before returning html (#27019) 2026-03-18 14:15:48 -04:00
Jason Rasmussen
38b135ff36 fix: bounding box return type (#27014) 2026-03-18 11:58:40 -04:00
Jason Rasmussen
cda4a2a5fc fix: filter after searching by asset id (#26994)
* fix: filter after searching by asset id

* Update web/src/lib/modals/SearchFilterModal.svelte

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>

---------

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
2026-03-18 13:32:54 +00:00
Min Idzelis
88002cf7fe fix(web): allow images to be downloaded again(long-press or right click) (#26992) 2026-03-18 12:40:36 +01:00
Andreas Heinz
694ea151f5 fix(web): escape handling for tagging and adding a face in asset viewer (#26870) 2026-03-18 12:39:25 +01:00
Jason Rasmussen
b092c8b601 fix: healthcheck (#26989) 2026-03-17 17:54:39 -04:00
Jason Rasmussen
48e6e17829 feat: primary notifications (#26988) 2026-03-17 17:54:11 -04:00
Jason Rasmussen
0519833d75 refactor: prefer tv (#26993) 2026-03-17 17:53:48 -04:00
Thomas
34caed3b2b fix(server): flaky metadata tests (#26964) 2026-03-17 18:06:22 +01:00
Thomas
677cb660f5 fix(mobile): reflect asset deletions instantly (#26835)
Sometimes the current asset won't update when deleted, or it won't
refresh until an event (like showing details) happens.
2026-03-17 06:43:14 -05:00
Michel Heusschen
9b0b2bfcf2 fix(web): jump to primary stacked asset from memory (#26978) 2026-03-17 06:39:39 -05:00
Preslav Penchev
ac6938a629 fix(web): allow pasting PIN code from clipboard or password manager (#26944)
* fix(web): allow pasting PIN code from clipboard or password manager

The keydown handler was blocking Ctrl+V/Cmd+V since it called
preventDefault() on all non-numeric keys. Also adds an onpaste
handler to distribute pasted digits across the individual inputs.

* refactor: handle paste in handleInput, remove maxlength

* cleanup + fix digit focus

---------

Co-authored-by: Preslav Penchev <preslav.penchev@acronis.com>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
2026-03-17 06:38:06 -05:00
Thomas
16749ff8ba fix(server): sync files to disk (#26881)
Ensure that all files are flushed after they've been written.

At current, files are not explicitly flushed to disk, which can cause
data corruption. In extreme circumstances, it's possible that uploaded
files may not ever be persisted at all.
2026-03-17 06:33:43 -05:00
renovate[bot]
bba4a00eb1 chore(deps): update github-actions (#26967)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 10:40:22 +01:00
Mees Frensel
9dafc8e8e9 fix(web): make link fit album card (#26958) 2026-03-16 19:17:55 +01:00
Michel Heusschen
4e44fb9cf7 fix(web): prevent search page error on missing album filter (#26948) 2026-03-16 19:15:20 +01:00
Yaros
82db581cc5 feat(mobile): open in browser (#26369)
* feat(mobile): open in browser

* chore: open in browser instead of webview

* chore: allow archived asset

* fix: moved openinbrowser above unstack

* feat: deeplink into favorites, trash & archived

* fix: use remoteId (for tests to succeed)

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-16 18:06:51 +00:00
Mert
b66c97b785 fix(mobile): use shared auth for background_downloader (#26911)
shared client for background_downloader on ios
2026-03-13 22:23:07 -05:00
Mert
ff936f901d fix(mobile): duplicate server urls returned (#26864)
remove server url

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-13 22:09:42 -05:00
Min Idzelis
48fe111daa feat(web): improve OCR overlay text fitting, reactivity, and accessibility (#26678)
- Precise font sizing using canvas measureText instead of character-count heuristic
- Fix overlay repositioning on viewport resize by computing metrics from reactive state instead of DOM reads
- Fix animation delay on resize by using transition-colors instead of transition-all
- Add keyboard accessibility: OCR boxes are focusable via Tab with reading-order sort
- Show text on focus (same styling as hover) with proper ARIA attributes
2026-03-13 22:04:55 -05:00
bo0tzz
0581b49750 fix: ignore optional headers in pr template check (#26910) 2026-03-13 22:55:00 +00:00
rthrth-svg
2c6d4f3fe1 fix(web): copy yearMonth in MonthGroup to avoid shared object reference with asset (#26890)
Co-authored-by: Min Idzelis <min123@gmail.com>
2026-03-13 22:27:08 +01:00
Belnadifia
55513cd59f feat(server): support IDPs that only send the userinfo in the ID token (#26717)
Co-authored-by: irouply <irouply@secom.fr>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-03-13 22:14:45 +01:00
bo0tzz
10fa928abe feat: require pull requests to follow template (#26902)
* feat: require pull requests to follow template

* fix: persist-credentials: false
2026-03-13 09:43:00 -05:00
Nathaniel Hourt
e322d44f95 fix: SMTP over TLS (#26893)
Final step on #22833

PReq #22833 is about adding support for SMTP-over-TLS rather than just STARTTLS when sending emails. That PReq adds almost everything; it just forgot to actually pass the flag to Nodemailer at the end.

This adds that last line of code and makes it work correctly (for me, anyways!).

Co-authored-by: Nathaniel <I@nathaniel.land>
2026-03-13 09:41:50 -05:00
Michel Heusschen
c2a279e49e fix(web): keep header fixed on individual shared links (#26892) 2026-03-13 09:40:04 -05:00
Mert
226b9390db fix(mobile): video auth (#26887)
* fix video auth

* update commit
2026-03-13 09:38:21 -05:00
Michel Heusschen
754f072ef9 fix(web): disable drag and drop for internal items (#26897) 2026-03-13 09:37:51 -05:00
luis15pt
c91d8745b4 fix: use correct original URL for 360 video panorama playback (#26831)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:27:44 +01:00
Brandon Wees
f3b7cd6198 refactor: move encoded video to asset files table (#26863)
* refactor: move encoded video to asset files table

* chore: update
2026-03-12 16:15:21 -04:00
Jason Rasmussen
990aff441b fix: add to shared link (#26886) 2026-03-12 16:10:55 -04:00
Daniel Dietzler
001d7d083f refactor: small test factories (#26862) 2026-03-12 14:48:49 -04:00
Michel Heusschen
3fd24e2083 fix(server): restrict individual shared link asset removal to owners (#26868)
* fix(server): restrict individual shared link asset removal to owners

* make open-api
2026-03-12 14:48:00 -04:00
Jason Rasmussen
6bb8f4fcc4 refactor: clean class (#26885) 2026-03-12 14:47:35 -04:00
Jason Rasmussen
d4605b21d9 refactor: external links (#26880) 2026-03-12 14:55:33 +00:00
Jason Rasmussen
3bd37ebbfb refactor: clean class (#26879) 2026-03-12 09:53:46 -05:00
Min Idzelis
5c3777ab46 fix(web): fix zoom touch event handling (#26866)
fix(web): fix zoom touch event handling and add clarifying comments

- Suppress Safari's synthetic dblclick on double-tap which conflicts with zoom-image's touchstart-based zoom
- Add comment explaining pointer-events-none on zoom transform wrapper
- Add comments for touchAction and overflow style overrides
2026-03-12 09:37:29 -05:00
Alex
6c531e0a5a chore: add shadow to video play/pause icon shadow (#26836) 2026-03-11 14:15:31 -05:00
Thomas
471c27cd33 chore(mobile): remove background from asset viewer back button (#26851)
We recently changed the asset viewer to use a gradient. The circle
button looks out of place now.
2026-03-11 14:15:18 -05:00
bo0tzz
4773788a88 chore: more unused release workflow cleanup (#26817) 2026-03-11 20:04:26 +01:00
renovate[bot]
d49d995611 chore(deps): update dependency exiftool-vendored to v35.13.1 (#26813)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-11 20:03:19 +01:00
Snowknight26
0ac3d6a83a fix(web): face selection box position resetting on browser resize (#26766) 2026-03-11 19:38:08 +01:00
Mees Frensel
9996ee12d0 refactor(web): crop area tool (#26843) 2026-03-11 18:58:26 +01:00
225 changed files with 2826 additions and 2860 deletions

View File

@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json

80
.github/workflows/check-pr-template.yml vendored Normal file
View File

@@ -0,0 +1,80 @@
name: Check PR Template
on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited]
permissions: {}
jobs:
parse:
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.head.repo.fork == true }}
permissions:
contents: read
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Check required sections
id: check
env:
BODY: ${{ github.event.pull_request.body }}
run: |
OK=true
while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
act:
runs-on: ubuntu-latest
needs: parse
permissions:
pull-requests: write
steps:
- name: Close PR
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
}) {
__typename
}
closePullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'
- name: Reopen PR (sections now present, PR closed)
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f query='
mutation ReopenPR($prId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
}'

View File

@@ -42,10 +42,10 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with:
category: '/language:${{matrix.language}}'

View File

@@ -67,10 +67,10 @@ jobs:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './docs/.nvmrc'
cache: 'pnpm'

View File

@@ -29,10 +29,10 @@ jobs:
persist-credentials: true
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -63,13 +63,13 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -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');
}

View File

@@ -30,10 +30,10 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

@@ -75,9 +75,9 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -119,9 +119,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -166,9 +166,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -208,9 +208,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -252,9 +252,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -290,9 +290,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -338,9 +338,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -385,9 +385,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -424,9 +424,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -496,9 +496,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -620,7 +620,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
with:
python-version: 3.11
- name: Install dependencies
@@ -661,9 +661,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
@@ -712,9 +712,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -774,9 +774,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -81,7 +81,7 @@ export const connect = async (url: string, key: string) => {
const [error] = await withError(getMyUser());
if (isHttpError(error)) {
logError(error, 'Failed to connect to server');
logError(error, `Failed to connect to server ${url}`);
process.exit(1);
}

View File

@@ -10,6 +10,7 @@ export enum OAuthClient {
export enum OAuthUser {
NO_EMAIL = 'no-email',
NO_NAME = 'no-name',
ID_TOKEN_CLAIMS = 'id-token-claims',
WITH_QUOTA = 'with-quota',
WITH_USERNAME = 'with-username',
WITH_ROLE = 'with-role',
@@ -52,12 +53,25 @@ const withDefaultClaims = (sub: string) => ({
email_verified: true,
});
const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
const getClaims = (sub: string, use?: string) => {
if (sub === OAuthUser.ID_TOKEN_CLAIMS) {
return {
sub,
email: `oauth-${sub}@immich.app`,
email_verified: true,
name: use === 'id_token' ? 'ID Token User' : 'Userinfo User',
};
}
return claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
};
const setup = async () => {
const { privateKey, publicKey } = await generateKeyPair('RS256');
const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect'];
const redirectUris = [
'http://127.0.0.1:2285/auth/login',
'https://photos.immich.app/oauth/mobile-redirect',
];
const port = 2286;
const host = '0.0.0.0';
const oidc = new Provider(`http://${host}:${port}`, {
@@ -66,7 +80,10 @@ const setup = async () => {
console.error(error);
ctx.body = 'Internal Server Error';
},
findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }),
findAccount: (ctx, sub) => ({
accountId: sub,
claims: (use) => getClaims(sub, use),
}),
scopes: ['openid', 'email', 'profile'],
claims: {
openid: ['sub'],
@@ -94,6 +111,7 @@ const setup = async () => {
state: 'oidc.state',
},
},
conformIdTokenClaims: false,
pkce: {
required: () => false,
},
@@ -125,7 +143,10 @@ const setup = async () => {
],
});
const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
const onStart = () =>
console.log(
`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`,
);
const app = oidc.listen(port, host, onStart);
return () => app.close();
};

View File

@@ -380,4 +380,23 @@ describe(`/oauth`, () => {
});
});
});
describe('idTokenClaims', () => {
it('should use claims from the ID token if IDP includes them', async () => {
await setupOAuth(admin.accessToken, {
enabled: true,
clientId: OAuthClient.DEFAULT,
clientSecret: OAuthClient.DEFAULT,
});
const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS);
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(201);
expect(body).toMatchObject({
accessToken: expect.any(String),
name: 'ID Token User',
userEmail: 'oauth-id-token-claims@immich.app',
userId: expect.any(String),
});
});
});
});

View File

@@ -438,6 +438,16 @@ describe('/shared-links', () => {
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
});
it('should reject guests removing assets from an individual shared link', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`)
.query({ key: linkWithAssets.key })
.send({ assetIds: [asset1.id] });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should remove assets from a shared link (individual)', async () => {
const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`)

View File

@@ -64,17 +64,17 @@ test.describe('Photo Viewer', () => {
await expect(original).toHaveAttribute('src', /fullsize/);
});
test('reloads photo when checksum changes', async ({ page }) => {
test('right-click targets the img element', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const initialSrc = await preview.getAttribute('src');
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
await utils.replaceAsset(admin.accessToken, asset.id);
await websocketEvent;
await expect(preview).not.toHaveAttribute('src', initialSrc!);
const box = await preview.boundingBox();
const tagAtCenter = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName, {
x: box!.x + box!.width / 2,
y: box!.y + box!.height / 2,
});
expect(tagAtCenter).toBe('IMG');
});
});

View File

@@ -12,15 +12,18 @@ import { asBearerAuth, utils } from 'src/utils';
test.describe('Shared Links', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
let asset2: AssetMediaResponseDto;
let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto;
let sharedLinkPassword: SharedLinkResponseDto;
let individualSharedLink: SharedLinkResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
asset2 = await utils.createAsset(admin.accessToken);
album = await createAlbum(
{
createAlbumDto: {
@@ -39,6 +42,10 @@ test.describe('Shared Links', () => {
albumId: album.id,
password: 'test-password',
});
individualSharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id, asset2.id],
});
});
test('download from a shared link', async ({ page }) => {
@@ -109,4 +116,21 @@ test.describe('Shared Links', () => {
await page.waitForURL('/photos');
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
});
test('owner can remove assets from an individual shared link', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/share/${individualSharedLink.key}`);
await page.locator(`[data-asset="${asset.id}"]`).waitFor();
await expect(page.locator(`[data-asset]`)).toHaveCount(2);
await page.locator(`[data-asset="${asset.id}"]`).hover();
await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click();
await page.getByRole('button', { name: 'Remove from shared link' }).click();
await page.getByRole('button', { name: 'Remove', exact: true }).click();
await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0);
await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1);
});
});

View File

@@ -375,40 +375,6 @@ export const utils = {
return body as AssetMediaResponseDto;
},
replaceAsset: async (
accessToken: string,
assetId: string,
dto?: Partial<Omit<AssetMediaCreateDto, 'assetData'>> & { assetData?: FileData },
) => {
const _dto = {
deviceAssetId: 'test-1',
deviceId: 'test',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
...dto,
};
const assetData = dto?.assetData?.bytes || makeRandomImage();
const filename = dto?.assetData?.filename || 'example.png';
if (dto?.assetData?.bytes) {
console.log(`Uploading ${filename}`);
}
const builder = request(app)
.put(`/assets/${assetId}/original`)
.attach('assetData', assetData, filename)
.set('Authorization', `Bearer ${accessToken}`);
for (const [key, value] of Object.entries(_dto)) {
void builder.field(key, String(value));
}
const { body } = await builder;
return body as AssetMediaResponseDto;
},
createImageFile: (path: string) => {
if (!existsSync(dirname(path))) {
mkdirSync(dirname(path), { recursive: true });

View File

@@ -1007,6 +1007,8 @@
"editor_edits_applied_success": "Edits applied successfully",
"editor_flip_horizontal": "Flip horizontal",
"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_reset_all_changes": "Reset changes",
"editor_rotate_left": "Rotate 90° counterclockwise",
@@ -1649,6 +1651,7 @@
"only_favorites": "Only favorites",
"open": "Open",
"open_calendar": "Open calendar",
"open_in_browser": "Open in browser",
"open_in_map_view": "Open in map view",
"open_in_openstreetmap": "Open in OpenStreetMap",
"open_the_search_filters": "Open the search filters",

View File

@@ -113,6 +113,8 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
implementation("androidx.media3:media3-datasource-okhttp:1.9.2")
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"

View File

@@ -12,6 +12,7 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.core.NetworkApiPlugin
import me.albemala.native_video_player.NativeVideoPlayerPlugin
import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
@@ -31,6 +32,7 @@ class MainActivity : FlutterFragmentActivity() {
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
flutterEngine.plugins.add(NetworkApiPlugin())
val messenger = flutterEngine.dartExecutor.binaryMessenger

View File

@@ -3,7 +3,13 @@ package app.alextran.immich.core
import android.content.Context
import android.content.SharedPreferences
import android.security.KeyChain
import androidx.annotation.OptIn
import androidx.core.content.edit
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cronet.CronetDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import app.alextran.immich.BuildConfig
import app.alextran.immich.NativeBuffer
import okhttp3.Cache
@@ -16,15 +22,22 @@ import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.io.File
import java.net.Authenticator
import java.net.CookieHandler
import java.net.PasswordAuthentication
import java.net.Socket
import java.net.URI
import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
@@ -56,6 +69,7 @@ private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
*/
object HttpClientManager {
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB
private const val KEEP_ALIVE_CONNECTIONS = 10
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
private const val MAX_REQUESTS_PER_HOST = 64
@@ -63,12 +77,15 @@ object HttpClientManager {
private var initialized = false
private val clientChangedListeners = mutableListOf<() -> Unit>()
@JvmStatic
lateinit var client: OkHttpClient
private set
private lateinit var client: OkHttpClient
private lateinit var appContext: Context
private lateinit var prefs: SharedPreferences
var cronetEngine: CronetEngine? = null
private set
private lateinit var cronetStorageDir: File
val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4)
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
var keyChainAlias: String? = null
@@ -81,9 +98,6 @@ object HttpClientManager {
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
val serverUrl: String? get() = if (initialized) prefs.getString(PREFS_SERVER_URLS, null)
?.let { Json.decodeFromString<List<String>>(it).firstOrNull() } else null
fun initialize(context: Context) {
if (initialized) return
synchronized(this) {
@@ -94,6 +108,25 @@ object HttpClientManager {
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
cookieJar.init(prefs)
System.setProperty("http.agent", USER_AGENT)
Authenticator.setDefault(object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication? {
val url = requestingURL ?: return null
if (url.userInfo.isNullOrEmpty()) return null
val parts = url.userInfo.split(":", limit = 2)
return PasswordAuthentication(parts[0], parts.getOrElse(1) { "" }.toCharArray())
}
})
CookieHandler.setDefault(object : CookieHandler() {
override fun get(uri: URI, requestHeaders: Map<String, List<String>>): Map<String, List<String>> {
val httpUrl = uri.toString().toHttpUrlOrNull() ?: return emptyMap()
val cookies = cookieJar.loadForRequest(httpUrl)
if (cookies.isEmpty()) return emptyMap()
return mapOf("Cookie" to listOf(cookies.joinToString("; ") { "${it.name}=${it.value}" }))
}
override fun put(uri: URI, responseHeaders: Map<String, List<String>>) {}
})
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
if (savedHeaders != null) {
@@ -112,6 +145,10 @@ object HttpClientManager {
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir)
cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
cronetEngine = buildCronetEngine()
initialized = true
}
}
@@ -168,6 +205,11 @@ object HttpClientManager {
private var clientGlobalRef: Long = 0L
@JvmStatic
fun getClient(): OkHttpClient {
return client
}
fun getClientPointer(): Long {
if (clientGlobalRef == 0L) {
clientGlobalRef = NativeBuffer.createGlobalRef(client)
@@ -223,6 +265,53 @@ object HttpClientManager {
?.joinToString("; ") { "${it.name}=${it.value}" }
}
fun getAuthHeaders(url: String): Map<String, String> {
val result = mutableMapOf<String, String>()
headers.forEach { (key, value) -> result[key] = value }
loadCookieHeader(url)?.let { result["Cookie"] = it }
url.toHttpUrlOrNull()?.let { httpUrl ->
if (httpUrl.username.isNotEmpty()) {
result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password)
}
}
return result
}
fun rebuildCronetEngine(): CronetEngine {
val old = cronetEngine!!
cronetEngine = buildCronetEngine()
return old
}
val cronetStoragePath: File get() = cronetStorageDir
@OptIn(UnstableApi::class)
fun createDataSourceFactory(headers: Map<String, String>): DataSource.Factory {
return if (isMtls) {
OkHttpDataSource.Factory(client.newBuilder().cache(null).build())
} else {
ResolvingDataSource.Factory(
CronetDataSource.Factory(cronetEngine!!, cronetExecutor)
) { dataSpec ->
val newHeaders = dataSpec.httpRequestHeaders.toMutableMap()
newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString()))
newHeaders["Cache-Control"] = "no-store"
dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build()
}
}
}
private fun buildCronetEngine(): CronetEngine {
return CronetEngine.Builder(appContext)
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.setStoragePath(cronetStorageDir.absolutePath)
.setUserAgent(USER_AGENT)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES)
.build()
}
private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,

View File

@@ -32,18 +32,14 @@ data class Request(
)
@RequiresApi(Build.VERSION_CODES.Q)
fun ImageDecoder.Source.decodeBitmap(
target: Size = Size(0, 0),
allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT,
colorspace: ColorSpace? = null
): Bitmap {
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
if (target.width > 0 && target.height > 0) {
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
decoder.setTargetSampleSize(sample)
}
decoder.allocator = allocator
decoder.setTargetColorSpace(colorspace)
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}
}
@@ -232,11 +228,7 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap {
signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ImageDecoder.createSource(resolver, uri).decodeBitmap(
target,
ImageDecoder.ALLOCATOR_SOFTWARE,
ColorSpace.get(ColorSpace.Named.SRGB)
)
ImageDecoder.createSource(resolver, uri).decodeBitmap(target)
} else {
val ref =
Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig()

View File

@@ -1,27 +1,19 @@
package app.alextran.immich.images
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.os.Build
import android.os.CancellationSignal
import android.os.OperationCanceledException
import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.USER_AGENT
import kotlinx.coroutines.*
import okhttp3.Cache
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.chromium.net.CronetEngine
import org.chromium.net.CronetException
import org.chromium.net.UrlRequest
import org.chromium.net.UrlResponseInfo
@@ -35,25 +27,6 @@ import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
fun NativeByteBuffer.decodeBitmap(target: android.util.Size = android.util.Size(0, 0)): Bitmap {
try {
val byteBuffer = NativeBuffer.wrap(pointer, offset)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ImageDecoder.createSource(byteBuffer).decodeBitmap(target = target)
} else {
val bytes = ByteArray(offset)
byteBuffer.get(bytes)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IOException("Failed to decode image")
}
} finally {
free()
}
}
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal)
@@ -71,7 +44,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
override fun requestImage(
url: String,
requestId: Long,
preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
@@ -119,8 +92,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
}
}
object ImageFetcherManager {
private lateinit var appContext: Context
private object ImageFetcherManager {
private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher
private var initialized = false
@@ -129,7 +101,6 @@ object ImageFetcherManager {
if (initialized) return
synchronized(this) {
if (initialized) return
appContext = context.applicationContext
cacheDir = context.cacheDir
fetcher = build()
HttpClientManager.addClientChangedListener(::invalidate)
@@ -162,12 +133,12 @@ object ImageFetcherManager {
return if (HttpClientManager.isMtls) {
OkHttpImageFetcher.create(cacheDir)
} else {
CronetImageFetcher(appContext, cacheDir)
CronetImageFetcher()
}
}
}
internal sealed interface ImageFetcher {
private sealed interface ImageFetcher {
fun fetch(
url: String,
signal: CancellationSignal,
@@ -180,19 +151,11 @@ internal sealed interface ImageFetcher {
fun clearCache(onCleared: (Result<Long>) -> Unit)
}
internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
private val ctx = context
private var engine: CronetEngine
private val executor = Executors.newFixedThreadPool(4)
private class CronetImageFetcher : ImageFetcher {
private val stateLock = Any()
private var activeCount = 0
private var draining = false
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
init {
engine = build(context)
}
override fun fetch(
url: String,
@@ -209,30 +172,16 @@ internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetch
}
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) }
url.toHttpUrlOrNull()?.let { httpUrl ->
if (httpUrl.username.isNotEmpty()) {
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
}
val requestBuilder = HttpClientManager.cronetEngine!!
.newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor)
HttpClientManager.getAuthHeaders(url).forEach { (key, value) ->
requestBuilder.addHeader(key, value)
}
val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel)
request.start()
}
private fun build(ctx: Context): CronetEngine {
return CronetEngine.Builder(ctx)
.enableHttp2(true)
.enableQuic(true)
.enableBrotli(true)
.setStoragePath(storageDir.absolutePath)
.setUserAgent(USER_AGENT)
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
.build()
}
private fun onComplete() {
val didDrain = synchronized(stateLock) {
activeCount--
@@ -255,19 +204,16 @@ internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetch
}
private fun onDrained() {
engine.shutdown()
val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared
this.onCacheCleared = null
onCacheCleared
}
if (onCacheCleared == null) {
executor.shutdown()
} else {
if (onCacheCleared != null) {
val oldEngine = HttpClientManager.rebuildCronetEngine()
oldEngine.shutdown()
CoroutineScope(Dispatchers.IO).launch {
val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) }
// Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result
engine = build(ctx)
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
synchronized(stateLock) { draining = false }
onCacheCleared(result)
}
@@ -360,7 +306,7 @@ internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetch
}
}
private suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
@@ -382,7 +328,7 @@ internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetch
}
}
internal class OkHttpImageFetcher private constructor(
private class OkHttpImageFetcher private constructor(
private val client: OkHttpClient,
) : ImageFetcher {
private val stateLock = Any()
@@ -393,8 +339,8 @@ internal class OkHttpImageFetcher private constructor(
fun create(cacheDir: File): OkHttpImageFetcher {
val dir = File(cacheDir, "okhttp")
val client = HttpClientManager.client.newBuilder()
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
val client = HttpClientManager.getClient().newBuilder()
.cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES))
.build()
return OkHttpImageFetcher(client)

View File

@@ -0,0 +1,33 @@
package app.alextran.immich.widget
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
fun loadScaledBitmap(file: File, reqWidth: Int, reqHeight: Int): Bitmap? {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.absolutePath, options)
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
options.inJustDecodeBounds = false
return BitmapFactory.decodeFile(file.absolutePath, options)
}
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}

View File

@@ -1,12 +1,18 @@
package app.alextran.immich.widget
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.glance.*
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
import java.util.concurrent.TimeUnit
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
@@ -69,8 +75,18 @@ class ImageDownloadWorker(
)
}
fun cancel(context: Context, appWidgetId: Int) {
suspend fun cancel(context: Context, appWidgetId: Int) {
WorkManager.getInstance(context).cancelAllWorkByTag("$uniqueWorkName-$appWidgetId")
// delete cached image
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = widgetConfig[kImageUUID]
if (!currentImgUUID.isNullOrEmpty()) {
val file = File(context.cacheDir, imageFilename(currentImgUUID))
file.delete()
}
}
}
@@ -80,22 +96,43 @@ class ImageDownloadWorker(
val widgetId = inputData.getInt(kWorkerWidgetID, -1)
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
// clear state and go to "login" if no credentials
if (!ImmichAPI.isLoggedIn(context)) {
val currentAssetId = widgetConfig[kAssetId]
if (!currentAssetId.isNullOrEmpty()) {
updateWidget(glanceId, "", "", "immich://", WidgetState.LOG_IN)
val currentImgUUID = widgetConfig[kImageUUID]
val serverConfig = ImmichAPI.getServerConfig(context)
// clear any image caches and go to "login" state if no credentials
if (serverConfig == null) {
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
updateWidget(
glanceId,
"",
"",
"immich://",
WidgetState.LOG_IN
)
}
return Result.success()
}
// fetch new image
val entry = when (widgetType) {
WidgetType.RANDOM -> fetchRandom(widgetConfig)
WidgetType.MEMORIES -> fetchMemory()
WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig)
WidgetType.MEMORIES -> fetchMemory(serverConfig)
}
updateWidget(glanceId, entry.assetId, entry.subtitle, entry.deeplink)
// clear current image if it exists
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
}
// save a new image
val imgUUID = UUID.randomUUID().toString()
saveImage(entry.image, imgUUID)
// trigger the update routine with new image uuid
updateWidget(glanceId, imgUUID, entry.subtitle, entry.deeplink)
Result.success()
} catch (e: Exception) {
@@ -110,25 +147,28 @@ class ImageDownloadWorker(
private suspend fun updateWidget(
glanceId: GlanceId,
assetId: String,
imageUUID: String,
subtitle: String?,
deeplink: String?,
widgetState: WidgetState = WidgetState.SUCCESS
) {
updateAppWidgetState(context, glanceId) { prefs ->
prefs[kNow] = System.currentTimeMillis()
prefs[kAssetId] = assetId
prefs[kImageUUID] = imageUUID
prefs[kWidgetState] = widgetState.toString()
prefs[kSubtitleText] = subtitle ?: ""
prefs[kDeeplinkURL] = deeplink ?: ""
}
PhotoWidget().update(context, glanceId)
PhotoWidget().update(context,glanceId)
}
private suspend fun fetchRandom(
serverConfig: ServerConfig,
widgetConfig: Preferences
): WidgetEntry {
val api = ImmichAPI(serverConfig)
val filters = SearchFilters()
val albumId = widgetConfig[kSelectedAlbum]
val showSubtitle = widgetConfig[kShowAlbumName]
@@ -142,27 +182,31 @@ class ImageDownloadWorker(
filters.albumIds = listOf(albumId)
}
var randomSearch = ImmichAPI.fetchSearchResults(filters)
var randomSearch = api.fetchSearchResults(filters)
// handle an empty album, fallback to random
if (randomSearch.isEmpty() && albumId != null) {
randomSearch = ImmichAPI.fetchSearchResults(SearchFilters())
randomSearch = api.fetchSearchResults(SearchFilters())
subtitle = ""
}
val random = randomSearch.first()
ImmichAPI.fetchImage(random).free() // warm the HTTP disk cache
val image = api.fetchImage(random)
return WidgetEntry(
random.id,
image,
subtitle,
assetDeeplink(random)
)
}
private suspend fun fetchMemory(): WidgetEntry {
private suspend fun fetchMemory(
serverConfig: ServerConfig
): WidgetEntry {
val api = ImmichAPI(serverConfig)
val today = LocalDate.now()
val memories = ImmichAPI.fetchMemory(today)
val memories = api.fetchMemory(today)
val asset: Asset
var subtitle: String? = null
@@ -175,15 +219,26 @@ class ImageDownloadWorker(
subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago"
} else {
val filters = SearchFilters(size=1)
asset = ImmichAPI.fetchSearchResults(filters).first()
asset = api.fetchSearchResults(filters).first()
}
ImmichAPI.fetchImage(asset).free() // warm the HTTP disk cache
val image = api.fetchImage(asset)
return WidgetEntry(
asset.id,
image,
subtitle,
assetDeeplink(asset)
)
}
private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
file.delete()
}
private suspend fun saveImage(bitmap: Bitmap, uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
}
}

View File

@@ -1,97 +1,122 @@
package app.alextran.immich.widget
import android.content.Context
import android.os.CancellationSignal
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.images.ImageFetcherManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import app.alextran.immich.widget.model.*
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
object ImmichAPI {
class ImmichAPI(cfg: ServerConfig) {
companion object {
fun getServerConfig(context: Context): ServerConfig? {
val prefs = HomeWidgetPlugin.getData(context)
val serverURL = prefs.getString("widget_server_url", "") ?: ""
val sessionKey = prefs.getString("widget_auth_token", "") ?: ""
val customHeadersJSON = prefs.getString("widget_custom_headers", "") ?: ""
if (serverURL.isBlank() || sessionKey.isBlank()) {
return null
}
var customHeaders: Map<String, String> = HashMap<String, String>()
if (customHeadersJSON.isNotBlank()) {
val stringMapType = object : TypeToken<Map<String, String>>() {}.type
customHeaders = Gson().fromJson(customHeadersJSON, stringMapType)
}
return ServerConfig(
serverURL,
sessionKey,
customHeaders
)
}
}
private val gson = Gson()
private val serverEndpoint: String
get() = HttpClientManager.serverUrl ?: throw IllegalStateException("Not logged in")
private val serverConfig = cfg
private fun initialize(context: Context) {
HttpClientManager.initialize(context)
ImageFetcherManager.initialize(context)
}
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): URL {
val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}")
fun isLoggedIn(context: Context): Boolean {
initialize(context)
return HttpClientManager.serverUrl != null
}
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): String {
val url = StringBuilder("$serverEndpoint$endpoint")
if (params.isNotEmpty()) {
url.append("?")
url.append(params.joinToString("&") { (key, value) ->
"${java.net.URLEncoder.encode(key, "UTF-8")}=${java.net.URLEncoder.encode(value, "UTF-8")}"
})
for ((key, value) in params) {
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")
}
return url.toString()
return URL(urlString.toString())
}
private fun HttpURLConnection.applyCustomHeaders() {
serverConfig.customHeaders.forEach { (key, value) ->
setRequestProperty(key, value)
}
}
suspend fun fetchSearchResults(filters: SearchFilters): List<Asset> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/search/random")
val body = gson.toJson(filters).toRequestBody("application/json".toMediaType())
val request = Request.Builder().url(url).post(body).build()
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
applyCustomHeaders()
HttpClientManager.client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
val type = object : TypeToken<List<Asset>>() {}.type
gson.fromJson(responseBody, type)
doOutput = true
}
connection.outputStream.use {
OutputStreamWriter(it).use { writer ->
writer.write(gson.toJson(filters))
writer.flush()
}
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Asset>>() {}.type
gson.fromJson(response, type)
}
suspend fun fetchMemory(date: LocalDate): List<MemoryResult> = withContext(Dispatchers.IO) {
val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
val url = buildRequestURL("/memories", listOf("for" to iso8601))
val request = Request.Builder().url(url).get().build()
HttpClientManager.client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
val type = object : TypeToken<List<MemoryResult>>() {}.type
gson.fromJson(responseBody, type)
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
applyCustomHeaders()
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<MemoryResult>>() {}.type
gson.fromJson(response, type)
}
suspend fun fetchImage(asset: Asset): NativeByteBuffer = suspendCancellableCoroutine { cont ->
suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) {
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview", "edited" to "true"))
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
ImageFetcherManager.fetch(
url,
signal,
onSuccess = { buffer -> cont.resume(buffer) },
onFailure = { e -> cont.resumeWithException(e) }
)
val connection = url.openConnection()
val data = connection.getInputStream().readBytes()
BitmapFactory.decodeByteArray(data, 0, data.size)
?: throw Exception("Invalid image data")
}
suspend fun fetchAlbums(): List<Album> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/albums")
val request = Request.Builder().url(url).get().build()
HttpClientManager.client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
val type = object : TypeToken<List<Album>>() {}.type
gson.fromJson(responseBody, type)
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
applyCustomHeaders()
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Album>>() {}.type
gson.fromJson(response, type)
}
}

View File

@@ -0,0 +1,58 @@
package app.alextran.immich.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.*
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MemoryReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
}
}
override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
val provider = ComponentName(context, MemoryReceiver::class.java)
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
// Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) {
glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.MEMORIES)
}
}
// make sure the periodic jobs are running
glanceIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
}
super.onReceive(context, intent)
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
CoroutineScope(Dispatchers.Default).launch {
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
}
}
}

View File

@@ -2,12 +2,12 @@ package app.alextran.immich.widget
import android.content.Context
import android.content.Intent
import android.util.Size
import android.graphics.Bitmap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.*
import androidx.core.net.toUri
import androidx.datastore.preferences.core.MutablePreferences
import androidx.glance.appwidget.*
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.*
import androidx.glance.action.clickable
import androidx.glance.layout.*
@@ -18,28 +18,30 @@ import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import app.alextran.immich.R
import app.alextran.immich.images.decodeBitmap
import app.alextran.immich.widget.model.*
import java.io.File
class PhotoWidget : GlanceAppWidget() {
override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
val assetId = state[kAssetId]
val subtitle = state[kSubtitleText]
val deeplinkURL = state[kDeeplinkURL]?.toUri()
val widgetState = state[kWidgetState]
val bitmap = if (!assetId.isNullOrEmpty() && ImmichAPI.isLoggedIn(context)) {
try {
ImmichAPI.fetchImage(Asset(assetId, AssetType.IMAGE)).decodeBitmap(Size(500, 500))
} catch (e: Exception) {
null
}
} else null
provideContent {
val prefs = currentState<MutablePreferences>()
val imageUUID = prefs[kImageUUID]
val subtitle = prefs[kSubtitleText]
val deeplinkURL = prefs[kDeeplinkURL]?.toUri()
val widgetState = prefs[kWidgetState]
var bitmap: Bitmap? = null
if (imageUUID != null) {
// fetch a random photo from server
val file = File(context.cacheDir, imageFilename(imageUUID))
if (file.exists()) {
bitmap = loadScaledBitmap(file, 500, 500)
}
}
// WIDGET CONTENT
Box(

View File

@@ -4,11 +4,14 @@ import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import es.antonborri.home_widget.HomeWidgetPlugin
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.*
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
abstract class WidgetReceiver(private val widgetType: WidgetType) : GlanceAppWidgetReceiver() {
class RandomReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
@@ -19,25 +22,25 @@ abstract class WidgetReceiver(private val widgetType: WidgetType) : GlanceAppWid
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType)
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
}
}
override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
val provider = ComponentName(context, this::class.java)
val provider = ComponentName(context, RandomReceiver::class.java)
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
// Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) {
glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, widgetType)
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM)
}
}
// make sure the periodic jobs are running
glanceIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType)
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
}
super.onReceive(context, intent)
@@ -45,12 +48,10 @@ abstract class WidgetReceiver(private val widgetType: WidgetType) : GlanceAppWid
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
CoroutineScope(Dispatchers.Default).launch {
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
}
}
}
class MemoryReceiver : WidgetReceiver(WidgetType.MEMORIES)
class RandomReceiver : WidgetReceiver(WidgetType.RANDOM)

View File

@@ -71,18 +71,22 @@ fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId,
LaunchedEffect(Unit) {
// get albums from server
if (!ImmichAPI.isLoggedIn(context)) {
val serverCfg = ImmichAPI.getServerConfig(context)
if (serverCfg == null) {
state = WidgetConfigState.LOG_IN
return@LaunchedEffect
}
val api = ImmichAPI(serverCfg)
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE"
val currentAlbumName = currentState[kSelectedAlbumName] ?: "None"
var albumItems: List<DropdownItem>
try {
albumItems = ImmichAPI.fetchAlbums().map {
albumItems = api.fetchAlbums().map {
DropdownItem(it.albumName, it.id)
}

View File

@@ -1,5 +1,6 @@
package app.alextran.immich.widget.model
import android.graphics.Bitmap
import androidx.datastore.preferences.core.*
// MARK: Immich Entities
@@ -49,13 +50,19 @@ enum class WidgetConfigState {
}
data class WidgetEntry (
val assetId: String,
val image: Bitmap,
val subtitle: String?,
val deeplink: String?
)
data class ServerConfig(
val serverEndpoint: String,
val sessionKey: String,
val customHeaders: Map<String, String>
)
// MARK: Widget State Keys
val kAssetId = stringPreferencesKey("assetId")
val kImageUUID = stringPreferencesKey("uuid")
val kSubtitleText = stringPreferencesKey("subtitle")
val kNow = longPreferencesKey("now")
val kWidgetState = stringPreferencesKey("state")
@@ -68,6 +75,10 @@ const val kWorkerWidgetType = "widgetType"
const val kWorkerWidgetID = "widgetId"
const val kTriggeredFromApp = "triggeredFromApp"
fun imageFilename(id: String): String {
return "widget_image_$id.jpg"
}
fun assetDeeplink(asset: Asset): String {
return "immich://asset?id=${asset.id}"
}

View File

@@ -140,13 +140,6 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
A872EC0CA71550E4AB04E049 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Shared;
sourceTree = "<group>";
};
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@@ -264,7 +257,6 @@
97C146EF1CF9000F007C117D /* Products */,
0FB772A5B9601143383626CA /* Pods */,
1754452DD81DA6620E279E51 /* Frameworks */,
A872EC0CA71550E4AB04E049 /* Shared */,
);
sourceTree = "<group>";
};
@@ -370,7 +362,6 @@
F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
A872EC0CA71550E4AB04E049 /* Shared */,
B231F52D2E93A44A00BC45D1 /* Core */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
FEE084F22EC172080045228E /* Schemas */,
@@ -393,7 +384,6 @@
dependencies = (
);
fileSystemSynchronizedGroups = (
A872EC0CA71550E4AB04E049 /* Shared */,
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */,
);
name = WidgetExtension;

View File

@@ -1,5 +1,6 @@
import BackgroundTasks
import Flutter
import native_video_player
import network_info_plus
import path_provider_foundation
import permission_handler_apple
@@ -18,6 +19,8 @@ import UIKit
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
URLSessionManager.patchBackgroundDownloader()
GeneratedPluginRegistrant.register(with: self)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(with: controller.engine, controller: controller)

View File

@@ -1,7 +1,5 @@
import Foundation
#if canImport(native_video_player)
import native_video_player
#endif
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
let HEADERS_KEY = "immich.request_headers"
@@ -38,7 +36,7 @@ extension UserDefaults {
/// Old sessions are kept alive by Dart's FFI retain until all isolates release them.
class URLSessionManager: NSObject {
static let shared = URLSessionManager()
private(set) var session: URLSession
let delegate: URLSessionManagerDelegate
private static let cacheDir: URL = {
@@ -53,7 +51,7 @@ class URLSessionManager: NSObject {
diskCapacity: 1024 * 1024 * 1024,
directory: cacheDir
)
private static let userAgent: String = {
static let userAgent: String = {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
return "Immich_iOS_\(version)"
}()
@@ -146,7 +144,7 @@ class URLSessionManager: NSObject {
}
}
private static func buildSession(delegate: URLSessionDelegate) -> URLSession {
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
let config = URLSessionConfiguration.default
config.urlCache = urlCache
config.httpCookieStorage = cookieStorage
@@ -160,6 +158,49 @@ class URLSessionManager: NSObject {
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}
/// Patches background_downloader's URLSession to use shared auth configuration.
/// Must be called before background_downloader creates its session (i.e. early in app startup).
static func patchBackgroundDownloader() {
// Swizzle URLSessionConfiguration.background(withIdentifier:) to inject shared config
let originalSel = NSSelectorFromString("backgroundSessionConfigurationWithIdentifier:")
let swizzledSel = #selector(URLSessionConfiguration.immich_background(withIdentifier:))
if let original = class_getClassMethod(URLSessionConfiguration.self, originalSel),
let swizzled = class_getClassMethod(URLSessionConfiguration.self, swizzledSel) {
method_exchangeImplementations(original, swizzled)
}
// Add auth challenge handling to background_downloader's UrlSessionDelegate
guard let targetClass = NSClassFromString("background_downloader.UrlSessionDelegate") else { return }
let sessionBlock: @convention(block) (AnyObject, URLSession, URLAuthenticationChallenge,
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
= { _, session, challenge, completion in
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion)
}
class_replaceMethod(targetClass,
NSSelectorFromString("URLSession:didReceiveChallenge:completionHandler:"),
imp_implementationWithBlock(sessionBlock), "v@:@@@?")
let taskBlock: @convention(block) (AnyObject, URLSession, URLSessionTask, URLAuthenticationChallenge,
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
= { _, session, task, challenge, completion in
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion, task: task)
}
class_replaceMethod(targetClass,
NSSelectorFromString("URLSession:task:didReceiveChallenge:completionHandler:"),
imp_implementationWithBlock(taskBlock), "v@:@@@@?")
}
}
private extension URLSessionConfiguration {
@objc dynamic class func immich_background(withIdentifier id: String) -> URLSessionConfiguration {
// After swizzle, this calls the original implementation
let config = immich_background(withIdentifier: id)
config.httpCookieStorage = URLSessionManager.cookieStorage
config.httpAdditionalHeaders = ["User-Agent": URLSessionManager.userAgent]
return config
}
}
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
@@ -209,11 +250,9 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
let credential = URLCredential(identity: identity as! SecIdentity,
certificates: nil,
persistence: .forSession)
#if canImport(native_video_player)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
#endif
return completion(.useCredential, credential)
}
completion(.performDefaultHandling, nil)
@@ -230,11 +269,9 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
else {
return completion(.performDefaultHandling, nil)
}
#if canImport(native_video_player)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
#endif
let credential = URLCredential(user: user, password: password, persistence: .forSession)
completion(.useCredential, credential)
}

View File

@@ -9,7 +9,6 @@ struct ImageEntry: TimelineEntry {
var metadata: Metadata = Metadata()
struct Metadata: Codable {
var assetId: String? = nil
var subtitle: String? = nil
var error: WidgetError? = nil
var deepLink: URL? = nil
@@ -34,39 +33,80 @@ struct ImageEntry: TimelineEntry {
date: entryDate,
image: image,
metadata: EntryMetadata(
assetId: asset.id,
subtitle: subtitle,
deepLink: asset.deepLink
)
)
}
static func saveLast(for key: String, metadata: Metadata) {
if let data = try? JSONEncoder().encode(metadata) {
UserDefaults.group.set(data, forKey: "widget_last_\(key)")
func cache(for key: String) throws {
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.json"
)
// build metadata JSON
let entryMetadata = try JSONEncoder().encode(self.metadata)
// write to disk
try self.image?.pngData()?.write(to: imageURL, options: .atomic)
try entryMetadata.write(to: metadataURL, options: .atomic)
}
}
static func loadCached(for key: String, at date: Date = Date.now)
-> ImageEntry?
{
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.json"
)
guard let imageData = try? Data(contentsOf: imageURL),
let metadataJSON = try? Data(contentsOf: metadataURL),
let decodedMetadata = try? JSONDecoder().decode(
Metadata.self,
from: metadataJSON
)
else {
return nil
}
return ImageEntry(
date: date,
image: UIImage(data: imageData),
metadata: decodedMetadata
)
}
return nil
}
static func handleError(
for key: String,
api: ImmichAPI? = nil,
error: WidgetError = .fetchFailed
) async -> Timeline<ImageEntry> {
// Try to show the last image from the URL cache for transient failures
if error == .fetchFailed, let api = api,
let data = UserDefaults.group.data(forKey: "widget_last_\(key)"),
let cached = try? JSONDecoder().decode(Metadata.self, from: data),
let assetId = cached.assetId,
let image = try? await api.fetchImage(asset: Asset(id: assetId, type: .image))
) -> Timeline<ImageEntry> {
var timelineEntry = ImageEntry(
date: Date.now,
image: nil,
metadata: EntryMetadata(error: error)
)
// use cache if generic failed error
// we want to show the other errors to the user since without intervention,
// it will never succeed
if error == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key)
{
let entry = ImageEntry(date: Date.now, image: image, metadata: cached)
return Timeline(entries: [entry], policy: .atEnd)
timelineEntry = cachedEntry
}
return Timeline(
entries: [ImageEntry(date: Date.now, metadata: Metadata(error: error))],
policy: .atEnd
)
return Timeline(entries: [timelineEntry], policy: .atEnd)
}
}

View File

@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
import WidgetKit
// Constants and session configuration are in Shared/SharedURLSession.swift
let IMMICH_SHARE_GROUP = "group.app.immich.share"
enum WidgetError: Error, Codable {
case noLogin
@@ -104,48 +104,87 @@ struct Album: Codable, Equatable {
// MARK: API
class ImmichAPI {
let serverEndpoint: String
typealias CustomHeaders = [String:String]
struct ServerConfig {
let serverEndpoint: String
let sessionKey: String
let customHeaders: CustomHeaders
}
let serverConfig: ServerConfig
init() async throws {
guard let serverURLs = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY),
let serverURL = serverURLs.first,
!serverURL.isEmpty
// fetch the credentials from the UserDefaults store that dart placed here
guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP),
let serverURL = defaults.string(forKey: "widget_server_url"),
let sessionKey = defaults.string(forKey: "widget_auth_token")
else {
throw WidgetError.noLogin
}
serverEndpoint = serverURL
if serverURL == "" || sessionKey == "" {
throw WidgetError.noLogin
}
// custom headers come in the form of KV pairs in JSON
var customHeadersJSON = (defaults.string(forKey: "widget_custom_headers") ?? "")
var customHeaders: CustomHeaders = [:]
if customHeadersJSON != "",
let parsedHeaders = try? JSONDecoder().decode(CustomHeaders.self, from: customHeadersJSON.data(using: .utf8)!) {
customHeaders = parsedHeaders
}
serverConfig = ServerConfig(
serverEndpoint: serverURL,
sessionKey: sessionKey,
customHeaders: customHeaders
)
}
private func buildRequestURL(
serverConfig: ServerConfig,
endpoint: String,
params: [URLQueryItem] = []
) throws(FetchError) -> URL? {
guard let baseURL = URL(string: serverEndpoint) else {
throw FetchError.invalidURL
) -> URL? {
guard let baseURL = URL(string: serverConfig.serverEndpoint) else {
fatalError("Invalid base URL")
}
// Combine the base URL and API path
let fullPath = baseURL.appendingPathComponent(
endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
)
// Add the session key as a query parameter
var components = URLComponents(
url: fullPath,
resolvingAgainstBaseURL: false
)
if !params.isEmpty {
components?.queryItems = params
}
components?.queryItems = [
URLQueryItem(name: "sessionKey", value: serverConfig.sessionKey)
]
components?.queryItems?.append(contentsOf: params)
return components?.url
}
func applyCustomHeaders(for request: inout URLRequest) {
for (header, value) in serverConfig.customHeaders {
request.addValue(value, forHTTPHeaderField: header)
}
}
func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
async throws
-> [Asset]
{
// get URL
guard
let searchURL = try buildRequestURL(endpoint: "/search/random")
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/search/random"
)
else {
throw URLError(.badURL)
}
@@ -154,15 +193,20 @@ class ImmichAPI {
request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(filters)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request)
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
// decode data
return try JSONDecoder().decode([Asset].self, from: data)
}
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
// get URL
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
guard
let searchURL = try buildRequestURL(
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/memories",
params: memoryParams
)
@@ -172,8 +216,11 @@ class ImmichAPI {
var request = URLRequest(url: searchURL)
request.httpMethod = "GET"
applyCustomHeaders(for: &request)
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
let (data, _) = try await URLSession.shared.data(for: request)
// decode data
return try JSONDecoder().decode([MemoryResult].self, from: data)
}
@@ -182,7 +229,8 @@ class ImmichAPI {
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
guard
let fetchURL = try buildRequestURL(
let fetchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: assetEndpoint,
params: thumbnailParams
)
@@ -190,13 +238,9 @@ class ImmichAPI {
throw .invalidURL
}
let request = URLRequest(url: fetchURL)
guard let (data, _) = try? await URLSessionManager.shared.session.data(for: request) else {
throw .fetchFailed
}
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
throw .invalidImage
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
else {
throw .invalidURL
}
let decodeOptions: [NSString: Any] = [
@@ -219,16 +263,23 @@ class ImmichAPI {
}
func fetchAlbums() async throws -> [Album] {
// get URL
guard
let searchURL = try buildRequestURL(endpoint: "/albums")
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/albums"
)
else {
throw URLError(.badURL)
}
var request = URLRequest(url: searchURL)
request.httpMethod = "GET"
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request)
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
// decode data
return try JSONDecoder().decode([Album].self, from: data)
}
}

View File

@@ -0,0 +1,23 @@
//
// Utils.swift
// Runner
//
// Created by Alex Tran and Brandon Wees on 6/16/25.
//
import UIKit
extension UIImage {
/// Crops the image to ensure width and height do not exceed maxSize.
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
let canvas = CGSize(
width: width,
height: CGFloat(ceil(width / size.width * size.height))
)
let format = imageRendererFormat
format.opaque = isOpaque
return UIGraphicsImageRenderer(size: canvas, format: format).image {
_ in draw(in: CGRect(origin: .zero, size: canvas))
}
}
}

View File

@@ -24,14 +24,14 @@ struct ImmichMemoryProvider: TimelineProvider {
Task {
guard let api = try? await ImmichAPI() else {
completion(
await ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
)
return
}
guard let memories = try? await api.fetchMemory(for: Date.now)
else {
completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!)
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
return
}
@@ -58,7 +58,7 @@ struct ImmichMemoryProvider: TimelineProvider {
dateOffset: 0
)
else {
completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!)
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
return
}
@@ -78,7 +78,7 @@ struct ImmichMemoryProvider: TimelineProvider {
guard let api = try? await ImmichAPI() else {
completion(
await ImageEntry.handleError(for: cacheKey, error: .noLogin)
ImageEntry.handleError(for: cacheKey, error: .noLogin)
)
return
}
@@ -129,20 +129,20 @@ struct ImmichMemoryProvider: TimelineProvider {
// Load or save a cached asset for when network conditions are bad
if search.count == 0 {
completion(
await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
)
return
}
entries.append(contentsOf: search)
} catch {
completion(await ImageEntry.handleError(for: cacheKey, api: api))
completion(ImageEntry.handleError(for: cacheKey))
return
}
}
// save the last asset for fallback
ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata)
// cache the last image
try? entries.last!.cache(for: cacheKey)
completion(Timeline(entries: entries, policy: .atEnd))
}

View File

@@ -65,7 +65,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
let cacheKey = "random_none_\(context.family.rawValue)"
guard let api = try? await ImmichAPI() else {
return await ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
.first!
}
@@ -79,7 +79,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
dateOffset: 0
)
else {
return await ImageEntry.handleError(for: cacheKey, api: api).entries.first!
return ImageEntry.handleError(for: cacheKey).entries.first!
}
return entry
@@ -102,7 +102,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
// If we don't have a server config, return an entry with an error
guard let api = try? await ImmichAPI() else {
return await ImageEntry.handleError(for: cacheKey, error: .noLogin)
return ImageEntry.handleError(for: cacheKey, error: .noLogin)
}
// build entries
@@ -119,16 +119,16 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
// Load or save a cached asset for when network conditions are bad
if search.count == 0 {
return await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
}
entries.append(contentsOf: search)
} catch {
return await ImageEntry.handleError(for: cacheKey, api: api)
return ImageEntry.handleError(for: cacheKey)
}
// save the last asset for fallback
ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata)
// cache the last image
try? entries.last!.cache(for: cacheKey)
return Timeline(entries: entries, policy: .atEnd)
}

View File

@@ -33,6 +33,12 @@ const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64;
// Widget keys
const String appShareGroupId = "group.app.immich.share";
const String kWidgetAuthToken = "widget_auth_token";
const String kWidgetServerEndpoint = "widget_server_url";
const String kWidgetCustomHeaders = "widget_custom_headers";
// add widget identifiers here for new widgets
// these are used to force a widget refresh
// (iOSName, androidFQDN)

View File

@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).archive(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).archive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {

View File

@@ -57,13 +57,13 @@ class DeleteActionButton extends ConsumerWidget {
if (confirm != true) return;
}
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {

View File

@@ -35,13 +35,13 @@ class DeletePermanentActionButton extends ConsumerWidget {
false;
if (!confirm) return;
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'delete_permanently_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},

View File

@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'move_to_lock_folder_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:url_launcher/url_launcher.dart';
class OpenInBrowserActionButton extends ConsumerWidget {
final String remoteId;
final TimelineOrigin origin;
final bool iconOnly;
final bool menuItem;
final Color? iconColor;
const OpenInBrowserActionButton({
super.key,
required this.remoteId,
required this.origin,
this.iconOnly = false,
this.menuItem = false,
this.iconColor,
});
void _onTap() async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', '');
String originPath = '';
switch (origin) {
case TimelineOrigin.favorite:
originPath = '/favorites';
break;
case TimelineOrigin.trash:
originPath = '/trash';
break;
case TimelineOrigin.archive:
originPath = '/archive';
break;
default:
break;
}
final url = '$serverEndpoint$originPath/photos/$remoteId';
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
label: 'open_in_browser'.t(context: context),
iconData: Icons.open_in_browser,
iconColor: iconColor,
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: _onTap,
);
}
}

View File

@@ -29,13 +29,13 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
return;
}
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'remove_from_album_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},

View File

@@ -25,13 +25,13 @@ class TrashActionButton extends ConsumerWidget {
return;
}
final result = await ref.read(actionProvider.notifier).trash(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).trash(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'trash_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {

View File

@@ -16,13 +16,13 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).unArchive(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).unArchive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {

View File

@@ -81,19 +81,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
late int _currentPage = widget.initialIndex;
late int _totalAssets = ref.read(timelineServiceProvider).totalAssets;
StreamSubscription? _reloadSubscription;
KeepAliveLink? _stackChildrenKeepAlive;
bool _assetReloadRequested = false;
void _onTapNavigate(int direction) {
final page = _pageController.page?.toInt();
if (page == null) return;
final target = page + direction;
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
final maxPage = _totalAssets - 1;
if (target >= 0 && target <= maxPage) {
_currentPage = target;
_pageController.jumpToPage(target);
_onAssetChanged(target);
}
@@ -141,7 +139,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final page = _pageController.page?.round();
if (page != null && page != _currentPage) {
_currentPage = page;
_onAssetChanged(page);
}
return false;
@@ -153,8 +150,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _onAssetChanged(int index) async {
final timelineService = ref.read(timelineServiceProvider);
final asset = await timelineService.getAssetAsync(index);
_currentPage = index;
final asset = await ref.read(timelineServiceProvider).getAssetAsync(index);
if (asset == null) return;
AssetViewer._setAsset(ref, asset);
@@ -193,11 +191,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
case TimelineReloadEvent():
_onTimelineReloadEvent();
case ViewerReloadAssetEvent():
_assetReloadRequested = true;
_onViewerReloadEvent();
default:
}
}
void _onViewerReloadEvent() {
if (_totalAssets <= 1) return;
final index = _pageController.page?.round() ?? 0;
final target = index >= _totalAssets - 1 ? index - 1 : index + 1;
_pageController.animateToPage(target, duration: Durations.medium1, curve: Curves.easeInOut);
_onAssetChanged(target);
}
void _onTimelineReloadEvent() {
final timelineService = ref.read(timelineServiceProvider);
final totalAssets = timelineService.totalAssets;
@@ -207,43 +214,24 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
return;
}
var index = _pageController.page?.round() ?? 0;
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset != null) {
final newIndex = timelineService.getIndex(currentAsset.heroTag);
if (newIndex != null && newIndex != index) {
index = newIndex;
_currentPage = index;
_pageController.jumpToPage(index);
}
}
final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null;
final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1);
if (index >= totalAssets) {
index = totalAssets - 1;
_currentPage = index;
if (index != _currentPage) {
_pageController.jumpToPage(index);
_onAssetChanged(index);
} else if (currentAsset != null && assetIndex == null) {
_onAssetChanged(index);
}
if (_assetReloadRequested) {
_assetReloadRequested = false;
_onAssetReloadEvent(index);
if (_totalAssets != totalAssets) {
setState(() {
_totalAssets = totalAssets;
});
}
}
void _onAssetReloadEvent(int index) async {
final timelineService = ref.read(timelineServiceProvider);
final newAsset = await timelineService.getAssetAsync(index);
if (newAsset == null) return;
final currentAsset = ref.read(assetViewerProvider).currentAsset;
// Do not reload if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) return;
_onAssetChanged(index);
}
void _setSystemUIMode(bool controls, bool details) {
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
@@ -301,7 +289,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
: CurrentPlatform.isIOS
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: ref.read(timelineServiceProvider).totalAssets,
itemCount: _totalAssets,
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),

View File

@@ -113,17 +113,14 @@ class _AppBarBackButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
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(
padding: const EdgeInsets.only(left: 12.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent,
shape: const CircleBorder(),
iconSize: 22,
iconColor: foregroundColor,
iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white,
padding: EdgeInsets.zero,
elevation: showingDetails ? 4 : 0,
),

View File

@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -89,8 +87,9 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> logout() async {
try {
await _secureStorageService.delete(kSecuredPinCode);
await _widgetService.clearCredentials();
await _authService.logout();
unawaited(_widgetService.refreshWidgets());
await _ref.read(backgroundUploadServiceProvider).cancel();
_ref.read(foregroundUploadServiceProvider).cancel();
} finally {
@@ -127,7 +126,9 @@ class AuthNotifier extends StateNotifier<AuthState> {
await Store.put(StoreKey.accessToken, accessToken);
await _apiService.updateHeaders();
unawaited(_widgetService.refreshWidgets());
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final customHeaders = Store.tryGet(StoreKey.customHeaders);
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
// Get the deviceid from the store if it exists, otherwise generate a new one
String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;

View File

@@ -0,0 +1,20 @@
import 'package:home_widget/home_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final widgetRepositoryProvider = Provider((_) => const WidgetRepository());
class WidgetRepository {
const WidgetRepository();
Future<void> saveData(String key, String value) async {
await HomeWidget.saveWidgetData<String>(key, value);
}
Future<void> refresh(String iosName, String androidName) async {
await HomeWidget.updateWidget(iOSName: iosName, qualifiedAndroidName: androidName);
}
Future<void> setAppGroupId(String appGroupId) async {
await HomeWidget.setAppGroupId(appGroupId);
}
}

View File

@@ -176,10 +176,6 @@ class ApiService {
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final serverUrl = Store.tryGet(StoreKey.serverUrl);
if (serverUrl != null && serverUrl.isNotEmpty) {
urls.add(serverUrl);
}
final localEndpoint = Store.tryGet(StoreKey.localEndpoint);
if (localEndpoint != null && localEndpoint.isNotEmpty) {
urls.add(localEndpoint);

View File

@@ -1,15 +1,42 @@
import 'package:home_widget/home_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/repositories/widget.repository.dart';
final widgetServiceProvider = Provider((_) => const WidgetService());
final widgetServiceProvider = Provider((ref) {
return WidgetService(ref.watch(widgetRepositoryProvider));
});
class WidgetService {
const WidgetService();
final WidgetRepository _repository;
const WidgetService(this._repository);
Future<void> writeCredentials(String serverURL, String sessionKey, String? customHeaders) async {
await _repository.setAppGroupId(appShareGroupId);
await _repository.saveData(kWidgetServerEndpoint, serverURL);
await _repository.saveData(kWidgetAuthToken, sessionKey);
if (customHeaders != null && customHeaders.isNotEmpty) {
await _repository.saveData(kWidgetCustomHeaders, customHeaders);
}
// wait 3 seconds to ensure the widget is updated, dont block
Future.delayed(const Duration(seconds: 3), refreshWidgets);
}
Future<void> clearCredentials() async {
await _repository.setAppGroupId(appShareGroupId);
await _repository.saveData(kWidgetServerEndpoint, "");
await _repository.saveData(kWidgetAuthToken, "");
await _repository.saveData(kWidgetCustomHeaders, "");
// wait 3 seconds to ensure the widget is updated, dont block
Future.delayed(const Duration(seconds: 3), refreshWidgets);
}
Future<void> refreshWidgets() async {
for (final (iOSName, androidName) in kWidgetNames) {
await HomeWidget.updateWidget(iOSName: iOSName, qualifiedAndroidName: androidName);
await _repository.refresh(iOSName, androidName);
}
}
}

View File

@@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
@@ -75,6 +76,7 @@ enum ActionButtonType {
viewInTimeline,
download,
upload,
openInBrowser,
unstack,
archive,
unarchive,
@@ -149,6 +151,7 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.isStacked,
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
ActionButtonType.likeActivity =>
!context.isInLockedView &&
context.currentAlbum != null &&
@@ -236,6 +239,13 @@ enum ActionButtonType {
),
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
remoteId: context.asset.remoteId!,
origin: context.timelineOrigin,
iconOnly: iconOnly,
menuItem: menuItem,
iconColor: context.originalTheme?.iconTheme.color,
),
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
assetId: (context.asset as RemoteAsset).id,
iconOnly: iconOnly,

View File

@@ -1,12 +1,15 @@
import 'dart:ui';
import 'package:flutter/material.dart';
/// A widget that animates implicitly between a play and a pause icon.
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 bool playing;
final Color? color;
final List<Shadow>? shadows;
@override
State<StatefulWidget> createState() => AnimatedPlayPauseState();
@@ -39,12 +42,32 @@ class AnimatedPlayPauseState extends State<AnimatedPlayPause> with SingleTickerP
@override
Widget build(BuildContext context) {
final icon = AnimatedIcon(
color: widget.color,
size: widget.size,
icon: AnimatedIcons.play_pause,
progress: animationController,
);
return Center(
child: AnimatedIcon(
color: widget.color,
size: widget.size,
icon: AnimatedIcons.play_pause,
progress: animationController,
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,
],
),
);
}

View File

@@ -72,17 +72,14 @@ class VideoControls extends HookConsumerWidget {
children: [
Row(
children: [
IconTheme(
data: const IconThemeData(shadows: _controlShadows),
child: IconButton(
iconSize: 32,
padding: const EdgeInsets.all(12),
constraints: const BoxConstraints(),
icon: isFinished
? const Icon(Icons.replay, color: Colors.white, size: 32)
: AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying),
onPressed: () => _toggle(ref, isCasting),
),
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(

View File

@@ -113,7 +113,6 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | Get random assets
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video
*AssetsApi* | [**removeAssetEdits**](doc//AssetsApi.md#removeassetedits) | **DELETE** /assets/{id}/edits | Remove edits from an existing asset
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | Update an asset
*AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | Update asset metadata
@@ -149,7 +148,6 @@ Class | Method | HTTP request | Description
*DeprecatedApi* | [**getFullSyncForUser**](doc//DeprecatedApi.md#getfullsyncforuser) | **POST** /sync/full-sync | Get full sync for user
*DeprecatedApi* | [**getQueuesLegacy**](doc//DeprecatedApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | Get random assets
*DeprecatedApi* | [**replaceAsset**](doc//DeprecatedApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information

View File

@@ -1115,154 +1115,6 @@ class AssetsApi {
}
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<Response> replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/original'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['multipart/form-data'];
bool hasFields = false;
final mp = MultipartRequest('PUT', Uri.parse(apiPath));
if (assetData != null) {
hasFields = true;
mp.fields[r'assetData'] = assetData.field;
mp.files.add(assetData);
}
if (deviceAssetId != null) {
hasFields = true;
mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId);
}
if (deviceId != null) {
hasFields = true;
mp.fields[r'deviceId'] = parameterToString(deviceId);
}
if (duration != null) {
hasFields = true;
mp.fields[r'duration'] = parameterToString(duration);
}
if (fileCreatedAt != null) {
hasFields = true;
mp.fields[r'fileCreatedAt'] = parameterToString(fileCreatedAt);
}
if (fileModifiedAt != null) {
hasFields = true;
mp.fields[r'fileModifiedAt'] = parameterToString(fileModifiedAt);
}
if (filename != null) {
hasFields = true;
mp.fields[r'filename'] = parameterToString(filename);
}
if (hasFields) {
postBody = mp;
}
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<AssetMediaResponseDto?> replaceAsset(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
final response = await replaceAssetWithHttpInfo(id, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, duration: duration, filename: filename, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetMediaResponseDto',) as AssetMediaResponseDto;
}
return null;
}
/// Run an asset job
///
/// Run a specific job on a set of assets.

View File

@@ -363,154 +363,6 @@ class DeprecatedApi {
return null;
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<Response> replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/original'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['multipart/form-data'];
bool hasFields = false;
final mp = MultipartRequest('PUT', Uri.parse(apiPath));
if (assetData != null) {
hasFields = true;
mp.fields[r'assetData'] = assetData.field;
mp.files.add(assetData);
}
if (deviceAssetId != null) {
hasFields = true;
mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId);
}
if (deviceId != null) {
hasFields = true;
mp.fields[r'deviceId'] = parameterToString(deviceId);
}
if (duration != null) {
hasFields = true;
mp.fields[r'duration'] = parameterToString(duration);
}
if (fileCreatedAt != null) {
hasFields = true;
mp.fields[r'fileCreatedAt'] = parameterToString(fileCreatedAt);
}
if (fileModifiedAt != null) {
hasFields = true;
mp.fields[r'fileModifiedAt'] = parameterToString(fileModifiedAt);
}
if (filename != null) {
hasFields = true;
mp.fields[r'filename'] = parameterToString(filename);
}
if (hasFields) {
postBody = mp;
}
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<AssetMediaResponseDto?> replaceAsset(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
final response = await replaceAssetWithHttpInfo(id, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, duration: duration, filename: filename, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetMediaResponseDto',) as AssetMediaResponseDto;
}
return null;
}
/// Run jobs
///
/// Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.

View File

@@ -427,11 +427,7 @@ class SharedLinksApi {
/// * [String] id (required):
///
/// * [AssetIdsDto] assetIdsDto (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async {
Future<Response> removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/shared-links/{id}/assets'
.replaceAll('{id}', id);
@@ -443,13 +439,6 @@ class SharedLinksApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['application/json'];
@@ -473,12 +462,8 @@ class SharedLinksApi {
/// * [String] id (required):
///
/// * [AssetIdsDto] assetIdsDto (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<List<AssetIdsResponseDto>?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async {
final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto, key: key, slug: slug, );
Future<List<AssetIdsResponseDto>?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto,) async {
final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -41,7 +41,6 @@ class Permission {
static const assetPeriodView = Permission._(r'asset.view');
static const assetPeriodDownload = Permission._(r'asset.download');
static const assetPeriodUpload = Permission._(r'asset.upload');
static const assetPeriodReplace = Permission._(r'asset.replace');
static const assetPeriodCopy = Permission._(r'asset.copy');
static const assetPeriodDerive = Permission._(r'asset.derive');
static const assetPeriodEditPeriodGet = Permission._(r'asset.edit.get');
@@ -200,7 +199,6 @@ class Permission {
assetPeriodView,
assetPeriodDownload,
assetPeriodUpload,
assetPeriodReplace,
assetPeriodCopy,
assetPeriodDerive,
assetPeriodEditPeriodGet,
@@ -394,7 +392,6 @@ class PermissionTypeTransformer {
case r'asset.view': return Permission.assetPeriodView;
case r'asset.download': return Permission.assetPeriodDownload;
case r'asset.upload': return Permission.assetPeriodUpload;
case r'asset.replace': return Permission.assetPeriodReplace;
case r'asset.copy': return Permission.assetPeriodCopy;
case r'asset.derive': return Permission.assetPeriodDerive;
case r'asset.edit.get': return Permission.assetPeriodEditPeriodGet;

View File

@@ -1194,10 +1194,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -1218,8 +1218,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
url: "https://github.com/immich-app/native_video_player"
source: git
version: "1.3.1"
@@ -1897,10 +1897,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.7"
thumbhash:
dependency: "direct main"
description:

View File

@@ -56,7 +56,7 @@ dependencies:
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2'
ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
network_info_plus: ^6.1.3
octo_image: ^2.1.0
openapi:

View File

@@ -4216,89 +4216,6 @@
],
"x-immich-permission": "asset.download",
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Replace the asset with new file, without changing its id.",
"operationId": "replaceAsset",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/AssetMediaReplaceDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetMediaResponseDto"
}
}
},
"description": "Asset replaced successfully"
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Replace asset",
"tags": [
"Assets",
"Deprecated"
],
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v1",
"state": "Deprecated",
"replacementId": "copyAsset"
}
],
"x-immich-permission": "asset.replace",
"x-immich-state": "Deprecated"
}
},
"/assets/{id}/thumbnail": {
@@ -11605,22 +11522,6 @@
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
@@ -11677,6 +11578,7 @@
"state": "Stable"
}
],
"x-immich-permission": "sharedLink.update",
"x-immich-state": "Stable"
},
"put": {
@@ -16625,49 +16527,6 @@
],
"type": "object"
},
"AssetMediaReplaceDto": {
"properties": {
"assetData": {
"description": "Asset file data",
"format": "binary",
"type": "string"
},
"deviceAssetId": {
"description": "Device asset ID",
"type": "string"
},
"deviceId": {
"description": "Device ID",
"type": "string"
},
"duration": {
"description": "Duration (for videos)",
"type": "string"
},
"fileCreatedAt": {
"description": "File creation date",
"format": "date-time",
"type": "string"
},
"fileModifiedAt": {
"description": "File modification date",
"format": "date-time",
"type": "string"
},
"filename": {
"description": "Filename",
"type": "string"
}
},
"required": [
"assetData",
"deviceAssetId",
"deviceId",
"fileCreatedAt",
"fileModifiedAt"
],
"type": "object"
},
"AssetMediaResponseDto": {
"properties": {
"id": {
@@ -19714,7 +19573,6 @@
"asset.view",
"asset.download",
"asset.upload",
"asset.replace",
"asset.copy",
"asset.derive",
"asset.edit.get",

View File

@@ -1028,22 +1028,6 @@ export type AssetOcrResponseDto = {
/** Normalized y coordinate of box corner 4 (0-1) */
y4: number;
};
export type AssetMediaReplaceDto = {
/** Asset file data */
assetData: Blob;
/** Device asset ID */
deviceAssetId: string;
/** Device ID */
deviceId: string;
/** Duration (for videos) */
duration?: string;
/** File creation date */
fileCreatedAt: string;
/** File modification date */
fileModifiedAt: string;
/** Filename */
filename?: string;
};
export type SignUpDto = {
/** User email */
email: string;
@@ -4270,27 +4254,6 @@ export function downloadAsset({ edited, id, key, slug }: {
...opts
}));
}
/**
* Replace asset
*/
export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: {
id: string;
key?: string;
slug?: string;
assetMediaReplaceDto: AssetMediaReplaceDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetMediaResponseDto;
}>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({
key,
slug
}))}`, oazapfts.multipart({
...opts,
method: "PUT",
body: assetMediaReplaceDto
})));
}
/**
* View asset thumbnail
*/
@@ -5987,19 +5950,14 @@ export function updateSharedLink({ id, sharedLinkEditDto }: {
/**
* Remove assets from a shared link
*/
export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: {
export function removeSharedLinkAssets({ id, assetIdsDto }: {
id: string;
key?: string;
slug?: string;
assetIdsDto: AssetIdsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetIdsResponseDto[];
}>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({
key,
slug
}))}`, oazapfts.json({
}>(`/shared-links/${encodeURIComponent(id)}/assets`, oazapfts.json({
...opts,
method: "DELETE",
body: assetIdsDto
@@ -6925,7 +6883,6 @@ export enum Permission {
AssetView = "asset.view",
AssetDownload = "asset.download",
AssetUpload = "asset.upload",
AssetReplace = "asset.replace",
AssetCopy = "asset.copy",
AssetDerive = "asset.derive",
AssetEditGet = "asset.edit.get",

54
pnpm-lock.yaml generated
View File

@@ -248,7 +248,7 @@ importers:
version: 63.0.0(eslint@10.0.2(jiti@2.6.1))
exiftool-vendored:
specifier: ^35.0.0
version: 35.10.1
version: 35.13.1
globals:
specifier: ^17.0.0
version: 17.4.0
@@ -456,7 +456,7 @@ importers:
version: 4.4.0
exiftool-vendored:
specifier: ^35.0.0
version: 35.10.1
version: 35.13.1
express:
specifier: ^5.1.0
version: 5.2.1
@@ -845,6 +845,12 @@ importers:
tabbable:
specifier: ^6.2.0
version: 6.4.0
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
tailwind-variants:
specifier: ^3.2.2
version: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1)
thumbhash:
specifier: ^0.1.1
version: 0.1.1
@@ -3919,8 +3925,8 @@ packages:
peerDependencies:
'@photo-sphere-viewer/core': 5.14.1
'@photostructure/tz-lookup@11.4.0':
resolution: {integrity: sha512-yrFaDbQQZVJIzpCTnoghWO8Rttu22Hg7/JkfP3CM8UKniXYzD80cuv4UAsFkzP5Z6XWceWNsQTqUJHKyGNXzLg==}
'@photostructure/tz-lookup@11.5.0':
resolution: {integrity: sha512-0DVFriinZ7TeOnm9ytXeSL3NMFU87ZqMjgbPNkd8LgHFLcPg1BDyM1eewFYs+pPM+62S4fSP9Mtgijmn+6y95w==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
@@ -7210,17 +7216,17 @@ packages:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
exiftool-vendored.exe@13.51.0:
resolution: {integrity: sha512-Q49J2c4e+XSGYDJf9PYMVI/IUfUkHLRsPUeDJ2ZekEBVLuw2g7ye9x0vQGWZKwEeZTlnXol7SeBJB0wtAmzM9w==}
exiftool-vendored.exe@13.52.0:
resolution: {integrity: sha512-8KSHKluRebjm2FL4S8rtwMLMELn/64CTI5BV3zmIdLnpS5N+aJEh6t9Y7aB7YBn5CwUao0T9/rxv4BMQqusukg==}
os: [win32]
exiftool-vendored.pl@13.51.0:
resolution: {integrity: sha512-RhDM10w4kv5YNCvECj0aLXZXi0UWyzVo2OS4P/hpmyCHL+NGCkZ6N9z/Yc3ek0cEfCj4AiLhe8C96pnz/Fw9Yg==}
exiftool-vendored.pl@13.52.0:
resolution: {integrity: sha512-DXsMRRNdjordn1Ckcp1h9OQJRQy9VDDOcs60H+3IP+W9zRnpSU3HqQMhAVKyHR4FzioiGDbREN9BI/M1oDNoEw==}
os: ['!win32']
hasBin: true
exiftool-vendored@35.10.1:
resolution: {integrity: sha512-orD61HdNcdlegfD80wI+3JE/n+iobYPztpFqv2drLHb1rb2QEKR1QY62r+O0wZHHNIf3Bje+xjweS1hxWignQA==}
exiftool-vendored@35.13.1:
resolution: {integrity: sha512-RiXz8RrJSBQ5jiZA1yMicmE/FgEFK/4QkU2KsqmlvTvouOOgANsNWv0f0uZbf098Ee933BE4bec5YAOBT0DuIQ==}
engines: {node: '>=20.0.0'}
expect-type@1.3.0:
@@ -11252,8 +11258,8 @@ packages:
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
tailwind-variants@3.2.2:
resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==}
@@ -14959,8 +14965,8 @@ snapshots:
simple-icons: 16.9.0
svelte: 5.53.7
svelte-highlight: 7.9.0
tailwind-merge: 3.4.0
tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1)
tailwind-merge: 3.5.0
tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1)
tailwindcss: 4.2.1
transitivePeerDependencies:
- '@sveltejs/kit'
@@ -15989,7 +15995,7 @@ snapshots:
'@photo-sphere-viewer/core': 5.14.1
three: 0.182.0
'@photostructure/tz-lookup@11.4.0': {}
'@photostructure/tz-lookup@11.5.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -19617,21 +19623,21 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
exiftool-vendored.exe@13.51.0:
exiftool-vendored.exe@13.52.0:
optional: true
exiftool-vendored.pl@13.51.0: {}
exiftool-vendored.pl@13.52.0: {}
exiftool-vendored@35.10.1:
exiftool-vendored@35.13.1:
dependencies:
'@photostructure/tz-lookup': 11.4.0
'@photostructure/tz-lookup': 11.5.0
'@types/luxon': 3.7.1
batch-cluster: 17.3.1
exiftool-vendored.pl: 13.51.0
exiftool-vendored.pl: 13.52.0
he: 1.2.0
luxon: 3.7.2
optionalDependencies:
exiftool-vendored.exe: 13.51.0
exiftool-vendored.exe: 13.52.0
expect-type@1.3.0: {}
@@ -24554,13 +24560,13 @@ snapshots:
tabbable@6.4.0: {}
tailwind-merge@3.4.0: {}
tailwind-merge@3.5.0: {}
tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1):
tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1):
dependencies:
tailwindcss: 4.2.1
optionalDependencies:
tailwind-merge: 3.4.0
tailwind-merge: 3.5.0
tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)):
dependencies:

View File

@@ -8,7 +8,6 @@ import {
Param,
ParseFilePipe,
Post,
Put,
Query,
Req,
Res,
@@ -28,10 +27,8 @@ import {
AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaReplaceDto,
AssetMediaSize,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -112,36 +109,6 @@ export class AssetMediaController {
await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger);
}
@Put(':id/original')
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiResponse({
status: 200,
description: 'Asset replaced successfully',
type: AssetMediaResponseDto,
})
@Endpoint({
summary: 'Replace asset',
description: 'Replace the asset with new file, without changing its id.',
history: new HistoryBuilder().added('v1').deprecated('v1', { replacementId: 'copyAsset' }),
})
@Authenticated({ permission: Permission.AssetReplace, sharedLink: true })
async replaceAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
files: UploadFiles,
@Body() dto: AssetMediaReplaceDto,
@Res({ passthrough: true }) res: Response,
): Promise<AssetMediaResponseDto> {
const { file } = getFiles(files);
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
res.status(HttpStatus.OK);
}
return responseDto;
}
@Get(':id/thumbnail')
@FileResponse()
@Authenticated({ permission: Permission.AssetView, sharedLink: true })

View File

@@ -1,7 +1,8 @@
import { SharedLinkController } from 'src/controllers/shared-link.controller';
import { SharedLinkType } from 'src/enum';
import { Permission, SharedLinkType } from 'src/enum';
import { SharedLinkService } from 'src/services/shared-link.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(SharedLinkController.name, () => {
@@ -31,4 +32,16 @@ describe(SharedLinkController.name, () => {
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null }));
});
});
describe('DELETE /shared-links/:id/assets', () => {
it('should require shared link update permission', async () => {
await request(ctx.getHttpServer()).delete(`/shared-links/${factory.uuid()}/assets`).send({ assetIds: [] });
expect(ctx.authenticate).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ permission: Permission.SharedLinkUpdate, sharedLinkRoute: false }),
}),
);
});
});
});

View File

@@ -180,7 +180,7 @@ export class SharedLinkController {
}
@Delete(':id/assets')
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.SharedLinkUpdate })
@Endpoint({
summary: 'Remove assets from a shared link',
description:

View File

@@ -154,10 +154,11 @@ export class StorageCore {
}
async moveAssetVideo(asset: StorageAsset) {
const encodedVideoFile = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
return this.moveFile({
entityId: asset.id,
pathType: AssetPathType.EncodedVideo,
oldPath: asset.encodedVideoPath,
oldPath: encodedVideoFile?.path || null,
newPath: StorageCore.getEncodedVideoPath(asset),
});
}
@@ -303,21 +304,15 @@ export class StorageCore {
case AssetPathType.Original: {
return this.assetRepository.update({ id, originalPath: newPath });
}
case AssetFileType.FullSize: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });
}
case AssetFileType.Preview: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath });
}
case AssetFileType.Thumbnail: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath });
}
case AssetPathType.EncodedVideo: {
return this.assetRepository.update({ id, encodedVideoPath: newPath });
}
case AssetFileType.FullSize:
case AssetFileType.EncodedVideo:
case AssetFileType.Thumbnail:
case AssetFileType.Preview:
case AssetFileType.Sidecar: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
return this.assetRepository.upsertFile({ assetId: id, type: pathType as AssetFileType, path: newPath });
}
case PersonPathType.Face: {
return this.personRepository.update({ id, thumbnailPath: newPath });
}

View File

@@ -154,7 +154,6 @@ export type StorageAsset = {
id: string;
ownerId: string;
files: AssetFile[];
encodedVideoPath: string | null;
};
export type Stack = {

View File

@@ -93,8 +93,6 @@ export class AssetMediaCreateDto extends AssetMediaBase {
[UploadFieldName.SIDECAR_DATA]?: any;
}
export class AssetMediaReplaceDto extends AssetMediaBase {}
export class AssetBulkUploadCheckItem {
@ApiProperty({ description: 'Asset ID' })
@IsString()

View File

@@ -153,7 +153,6 @@ export type MapAsset = {
duplicateId: string | null;
duration: string | null;
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
encodedVideoPath: string | null;
exifInfo?: ShallowDehydrateObject<Selectable<Exif>> | null;
faces?: ShallowDehydrateObject<AssetFace>[];
fileCreatedAt: Date;

View File

@@ -45,6 +45,7 @@ export enum AssetFileType {
Preview = 'preview',
Thumbnail = 'thumbnail',
Sidecar = 'sidecar',
EncodedVideo = 'encoded_video',
}
export enum AlbumUserRole {
@@ -104,7 +105,6 @@ export enum Permission {
AssetView = 'asset.view',
AssetDownload = 'asset.download',
AssetUpload = 'asset.upload',
AssetReplace = 'asset.replace',
AssetCopy = 'asset.copy',
AssetDerive = 'asset.derive',

View File

@@ -20,7 +20,7 @@ import {
MaintenanceStatusResponseDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ServerConfigDto, ServerPingResponse, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ImmichCookie } from 'src/enum';
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
@@ -52,6 +52,11 @@ export class MaintenanceWorkerController {
return this.service.getSystemConfig();
}
@Get('server/ping')
pingServer(): ServerPingResponse {
return this.service.ping();
}
@Get('server/version')
getServerVersion(): ServerVersionResponseDto {
return this.service.getVersion();

View File

@@ -12,7 +12,7 @@ import {
MaintenanceStatusResponseDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ServerConfigDto, ServerPingResponse, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { DatabaseLock, ImmichCookie, MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
@@ -121,6 +121,10 @@ export class MaintenanceWorkerService {
return ServerVersionResponseDto.fromSemVer(serverVersion);
}
ping(): ServerPingResponse {
return { res: 'pong' };
}
/**
* {@link _ApiService.ssr}
*/

View File

@@ -3,13 +3,16 @@ import { PATH_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
import { NextFunction, RequestHandler } from 'express';
import multer, { StorageEngine, diskStorage } from 'multer';
import multer from 'multer';
import { createHash, randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { pipeline } from 'node:stream';
import { Observable } from 'rxjs';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { RouteKey } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
@@ -26,8 +29,6 @@ export function getFiles(files: UploadFiles) {
};
}
type DiskStorageCallback = (error: Error | null, result: string) => void;
type ImmichMulterFile = Express.Multer.File & { uuid: string };
interface Callback<T> {
@@ -35,34 +36,21 @@ interface Callback<T> {
(error: null, result: T): void;
}
const callbackify = <T>(target: (...arguments_: any[]) => T, callback: Callback<T>) => {
try {
return callback(null, target());
} catch (error: Error | any) {
return callback(error);
}
};
@Injectable()
export class FileUploadInterceptor implements NestInterceptor {
private handlers: {
userProfile: RequestHandler;
assetUpload: RequestHandler;
};
private defaultStorage: StorageEngine;
constructor(
private reflect: Reflector,
private assetService: AssetMediaService,
private storageRepository: StorageRepository,
private logger: LoggingRepository,
) {
this.logger.setContext(FileUploadInterceptor.name);
this.defaultStorage = diskStorage({
filename: this.filename.bind(this),
destination: this.destination.bind(this),
});
const instance = multer({
fileFilter: this.fileFilter.bind(this),
storage: {
@@ -99,60 +87,60 @@ export class FileUploadInterceptor implements NestInterceptor {
}
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
}
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
callback as Callback<string>,
);
}
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
callback as Callback<string>,
);
try {
callback(null, this.assetService.canUploadFile(asUploadRequest(request, file)));
} catch (error: Error | any) {
callback(error);
}
}
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
(file as ImmichMulterFile).uuid = randomUUID();
request.on('error', (error) => {
this.logger.warn('Request error while uploading file, cleaning up', error);
this.assetService.onUploadError(request, file).catch(this.logger.error);
});
if (!this.isAssetUploadFile(file)) {
this.defaultStorage._handleFile(request, file, callback);
return;
}
try {
(file as ImmichMulterFile).uuid = randomUUID();
const hash = createHash('sha1');
file.stream.on('data', (chunk) => hash.update(chunk));
this.defaultStorage._handleFile(request, file, (error, info) => {
if (error) {
hash.destroy();
callback(error);
} else {
callback(null, { ...info, checksum: hash.digest() });
}
});
const uploadRequest = asUploadRequest(request, file);
const path = join(
this.assetService.getUploadFolder(uploadRequest),
this.assetService.getUploadFilename(uploadRequest),
);
const writeStream = this.storageRepository.createWriteStream(path);
const hash = file.fieldname === UploadFieldName.ASSET_DATA ? createHash('sha1') : null;
let size = 0;
file.stream.on('data', (chunk) => {
hash?.update(chunk);
size += chunk.length;
});
pipeline(file.stream, writeStream, (error) => {
if (error) {
hash?.destroy();
return callback(error);
}
callback(null, {
path,
size,
checksum: hash?.digest(),
});
});
} catch (error: Error | any) {
callback(error);
}
}
private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
this.defaultStorage._removeFile(request, file, callback);
}
private isAssetUploadFile(file: Express.Multer.File) {
switch (file.fieldname as UploadFieldName) {
case UploadFieldName.ASSET_DATA: {
return true;
}
}
return false;
private removeFile(_request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
this.storageRepository
.unlink(file.path)
.then(() => callback(null))
.catch(callback);
}
private getHandler(route: RouteKey) {

View File

@@ -175,7 +175,6 @@ where
select
"asset"."id",
"asset"."ownerId",
"asset"."encodedVideoPath",
(
select
coalesce(json_agg(agg), '[]')
@@ -463,7 +462,6 @@ select
"asset"."libraryId",
"asset"."ownerId",
"asset"."livePhotoVideoId",
"asset"."encodedVideoPath",
"asset"."originalPath",
"asset"."isOffline",
to_json("asset_exif") as "exifInfo",
@@ -521,12 +519,17 @@ select
from
"asset"
where
"asset"."type" = $1
and (
"asset"."encodedVideoPath" is null
or "asset"."encodedVideoPath" = $2
"asset"."type" = 'VIDEO'
and not exists (
select
"asset_file"."id"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = 'encoded_video'
)
and "asset"."visibility" != $3
and "asset"."visibility" != 'hidden'
and "asset"."deletedAt" is null
-- AssetJobRepository.getForVideoConversion
@@ -534,12 +537,27 @@ select
"asset"."id",
"asset"."ownerId",
"asset"."originalPath",
"asset"."encodedVideoPath"
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
) as agg
) as "files"
from
"asset"
where
"asset"."id" = $1
and "asset"."type" = $2
and "asset"."type" = 'VIDEO'
-- AssetJobRepository.streamForMetadataExtraction
select

View File

@@ -629,13 +629,21 @@ order by
-- AssetRepository.getForVideo
select
"asset"."encodedVideoPath",
"asset"."originalPath"
"asset"."originalPath",
(
select
"asset_file"."path"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as "encodedVideoPath"
from
"asset"
where
"asset"."id" = $1
and "asset"."type" = $2
"asset"."id" = $2
and "asset"."type" = $3
-- AssetRepository.getForOcr
select

View File

@@ -104,7 +104,7 @@ export class AssetJobRepository {
getForMigrationJob(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath'])
.select(['asset.id', 'asset.ownerId'])
.select(withFiles)
.where('asset.id', '=', id)
.executeTakeFirst();
@@ -268,7 +268,6 @@ export class AssetJobRepository {
'asset.libraryId',
'asset.ownerId',
'asset.livePhotoVideoId',
'asset.encodedVideoPath',
'asset.originalPath',
'asset.isOffline',
])
@@ -310,11 +309,21 @@ export class AssetJobRepository {
return this.db
.selectFrom('asset')
.select(['asset.id'])
.where('asset.type', '=', AssetType.Video)
.where('asset.type', '=', sql.lit(AssetType.Video))
.$if(!force, (qb) =>
qb
.where((eb) => eb.or([eb('asset.encodedVideoPath', 'is', null), eb('asset.encodedVideoPath', '=', '')]))
.where('asset.visibility', '!=', AssetVisibility.Hidden),
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('asset_file')
.select('asset_file.id')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', sql.lit(AssetFileType.EncodedVideo)),
),
),
)
.where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)),
)
.where('asset.deletedAt', 'is', null)
.stream();
@@ -324,9 +333,10 @@ export class AssetJobRepository {
getForVideoConversion(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath'])
.select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
.select(withFiles)
.where('asset.id', '=', id)
.where('asset.type', '=', AssetType.Video)
.where('asset.type', '=', sql.lit(AssetType.Video))
.executeTakeFirst();
}

View File

@@ -36,6 +36,7 @@ import {
withExif,
withFaces,
withFacesAndPeople,
withFilePath,
withFiles,
withLibrary,
withOwner,
@@ -1019,8 +1020,21 @@ export class AssetRepository {
.execute();
}
async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise<void> {
await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute();
async deleteFile({
assetId,
type,
edited,
}: {
assetId: string;
type: AssetFileType;
edited?: boolean;
}): Promise<void> {
await this.db
.deleteFrom('asset_file')
.where('assetId', '=', asUuid(assetId))
.where('type', '=', type)
.$if(edited !== undefined, (qb) => qb.where('isEdited', '=', edited!))
.execute();
}
async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> {
@@ -1139,7 +1153,8 @@ export class AssetRepository {
async getForVideo(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.encodedVideoPath', 'asset.originalPath'])
.select(['asset.originalPath'])
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath'))
.where('asset.id', '=', id)
.where('asset.type', '=', AssetType.Video)
.executeTakeFirst();

View File

@@ -431,7 +431,6 @@ export class DatabaseRepository {
.updateTable('asset')
.set((eb) => ({
originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]),
}))
.execute();

View File

@@ -162,6 +162,7 @@ export class EmailRepository {
host: options.host,
port: options.port,
tls: { rejectUnauthorized: !options.ignoreCert },
secure: options.secure,
auth:
options.username || options.password
? {

View File

@@ -70,7 +70,16 @@ export class OAuthRepository {
try {
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
let profile: OAuthProfile;
const tokenClaims = tokens.claims();
if (tokenClaims && 'email' in tokenClaims) {
this.logger.debug('Using ID token claims instead of userinfo endpoint');
profile = tokenClaims as OAuthProfile;
} else {
profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
}
if (!profile.sub) {
throw new Error('Unexpected profile response, no `sub`');
}

View File

@@ -4,7 +4,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Album, columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { SharedLinkType } from 'src/enum';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -249,6 +249,20 @@ export class SharedLinkRepository {
await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute();
}
@ChunkedArray({ paramIndex: 1 })
async addAssets(id: string, assetIds: string[]) {
if (assetIds.length === 0) {
return [];
}
return await this.db
.insertInto('shared_link_asset')
.values(assetIds.map((assetId) => ({ assetId, sharedLinkId: id })))
.onConflict((oc) => oc.doNothing())
.returning(['shared_link_asset.assetId'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
private getSharedLinks(id: string) {
return this.db

View File

@@ -63,7 +63,7 @@ export class StorageRepository {
}
createWriteStream(filepath: string): Writable {
return createWriteStream(filepath, { flags: 'w' });
return createWriteStream(filepath, { flags: 'w', flush: true });
}
createOrOverwriteFile(filepath: string, buffer: Buffer) {

View File

@@ -0,0 +1,25 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`
INSERT INTO "asset_file" ("assetId", "type", "path")
SELECT "id", 'encoded_video', "encodedVideoPath"
FROM "asset"
WHERE "encodedVideoPath" IS NOT NULL AND "encodedVideoPath" != '';
`.execute(db);
await sql`ALTER TABLE "asset" DROP COLUMN "encodedVideoPath";`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" ADD "encodedVideoPath" character varying DEFAULT '';`.execute(db);
await sql`
UPDATE "asset"
SET "encodedVideoPath" = af."path"
FROM "asset_file" af
WHERE "asset"."id" = af."assetId"
AND af."type" = 'encoded_video'
AND af."isEdited" = false;
`.execute(db);
}

View File

@@ -92,9 +92,6 @@ export class AssetTable {
@Column({ type: 'character varying', nullable: true })
duration!: string | null;
@Column({ type: 'character varying', nullable: true, default: '' })
encodedVideoPath!: string | null;
@Column({ type: 'bytea', index: true })
checksum!: Buffer; // sha1 checksum

View File

@@ -1,8 +1,10 @@
import { BadRequestException } from '@nestjs/common';
import { ReactionType } from 'src/dtos/activity.dto';
import { ActivityService } from 'src/services/activity.service';
import { ActivityFactory } from 'test/factories/activity.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { getForActivity } from 'test/mappers';
import { factory, newUuid, newUuids } from 'test/small.factory';
import { newUuid, newUuids } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe(ActivityService.name, () => {
@@ -24,7 +26,7 @@ describe(ActivityService.name, () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([]);
await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]);
await expect(sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId })).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined });
});
@@ -36,7 +38,7 @@ describe(ActivityService.name, () => {
mocks.activity.search.mockResolvedValue([]);
await expect(
sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }),
sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }),
).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true });
@@ -48,7 +50,9 @@ describe(ActivityService.name, () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([]);
await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]);
await expect(sut.getAll(AuthFactory.create(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual(
[],
);
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false });
});
@@ -61,7 +65,10 @@ describe(ActivityService.name, () => {
mocks.activity.getStatistics.mockResolvedValue({ comments: 1, likes: 3 });
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1, likes: 3 });
await expect(sut.getStatistics(AuthFactory.create(), { assetId, albumId })).resolves.toEqual({
comments: 1,
likes: 3,
});
});
});
@@ -70,18 +77,18 @@ describe(ActivityService.name, () => {
const [albumId, assetId] = newUuids();
await expect(
sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should create a comment', async () => {
const [albumId, assetId, userId] = newUuids();
const activity = factory.activity({ albumId, assetId, userId });
const activity = ActivityFactory.create({ albumId, assetId, userId });
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(getForActivity(activity));
await sut.create(factory.auth({ user: { id: userId } }), {
await sut.create(AuthFactory.create({ id: userId }), {
albumId,
assetId,
type: ReactionType.COMMENT,
@@ -99,38 +106,38 @@ describe(ActivityService.name, () => {
it('should fail because activity is disabled for the album', async () => {
const [albumId, assetId] = newUuids();
const activity = factory.activity({ albumId, assetId });
const activity = ActivityFactory.create({ albumId, assetId });
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(getForActivity(activity));
await expect(
sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should create a like', async () => {
const [albumId, assetId, userId] = newUuids();
const activity = factory.activity({ userId, albumId, assetId, isLiked: true });
const activity = ActivityFactory.create({ userId, albumId, assetId, isLiked: true });
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(getForActivity(activity));
mocks.activity.search.mockResolvedValue([]);
await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE });
await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.LIKE });
expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true });
});
it('should skip if like exists', async () => {
const [albumId, assetId] = newUuids();
const activity = factory.activity({ albumId, assetId, isLiked: true });
const activity = ActivityFactory.create({ albumId, assetId, isLiked: true });
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([getForActivity(activity)]);
await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE });
await sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.LIKE });
expect(mocks.activity.create).not.toHaveBeenCalled();
});
@@ -138,29 +145,29 @@ describe(ActivityService.name, () => {
describe('delete', () => {
it('should require access', async () => {
await expect(sut.delete(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.activity.delete).not.toHaveBeenCalled();
});
it('should let the activity owner delete a comment', async () => {
const activity = factory.activity();
const activity = ActivityFactory.create();
mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set([activity.id]));
mocks.activity.delete.mockResolvedValue();
await sut.delete(factory.auth(), activity.id);
await sut.delete(AuthFactory.create(), activity.id);
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
});
it('should let the album owner delete a comment', async () => {
const activity = factory.activity();
const activity = ActivityFactory.create();
mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set([activity.id]));
mocks.activity.delete.mockResolvedValue();
await sut.delete(factory.auth(), activity.id);
await sut.delete(AuthFactory.create(), activity.id);
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
});

View File

@@ -1,7 +1,10 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Permission } from 'src/enum';
import { ApiKeyService } from 'src/services/api-key.service';
import { factory, newUuid } from 'test/small.factory';
import { ApiKeyFactory } from 'test/factories/api-key.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { SessionFactory } from 'test/factories/session.factory';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe(ApiKeyService.name, () => {
@@ -14,8 +17,8 @@ describe(ApiKeyService.name, () => {
describe('create', () => {
it('should create a new key', async () => {
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.All] });
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.All] });
const key = 'super-secret';
mocks.crypto.randomBytesAsText.mockReturnValue(key);
@@ -34,8 +37,8 @@ describe(ApiKeyService.name, () => {
});
it('should not require a name', async () => {
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
const key = 'super-secret';
mocks.crypto.randomBytesAsText.mockReturnValue(key);
@@ -54,7 +57,9 @@ describe(ApiKeyService.name, () => {
});
it('should throw an error if the api key does not have sufficient permissions', async () => {
const auth = factory.auth({ apiKey: { permissions: [Permission.AssetRead] } });
const auth = AuthFactory.from()
.apiKey({ permissions: [Permission.AssetRead] })
.build();
await expect(sut.create(auth, { permissions: [Permission.AssetUpdate] })).rejects.toBeInstanceOf(
BadRequestException,
@@ -65,7 +70,7 @@ describe(ApiKeyService.name, () => {
describe('update', () => {
it('should throw an error if the key is not found', async () => {
const id = newUuid();
const auth = factory.auth();
const auth = AuthFactory.create();
mocks.apiKey.getById.mockResolvedValue(void 0);
@@ -77,8 +82,8 @@ describe(ApiKeyService.name, () => {
});
it('should update a key', async () => {
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
const newName = 'New name';
mocks.apiKey.getById.mockResolvedValue(apiKey);
@@ -93,8 +98,8 @@ describe(ApiKeyService.name, () => {
});
it('should update permissions', async () => {
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
const newPermissions = [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate];
mocks.apiKey.getById.mockResolvedValue(apiKey);
@@ -111,8 +116,8 @@ describe(ApiKeyService.name, () => {
describe('api key auth', () => {
it('should prevent adding Permission.all', async () => {
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
const auth = factory.auth({ apiKey: { permissions } });
const apiKey = factory.apiKey({ userId: auth.user.id, permissions });
const auth = AuthFactory.from().apiKey({ permissions }).build();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions });
mocks.apiKey.getById.mockResolvedValue(apiKey);
@@ -125,8 +130,8 @@ describe(ApiKeyService.name, () => {
it('should prevent adding a new permission', async () => {
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
const auth = factory.auth({ apiKey: { permissions } });
const apiKey = factory.apiKey({ userId: auth.user.id, permissions });
const auth = AuthFactory.from().apiKey({ permissions }).build();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions });
mocks.apiKey.getById.mockResolvedValue(apiKey);
@@ -138,8 +143,10 @@ describe(ApiKeyService.name, () => {
});
it('should allow removing permissions', async () => {
const auth = factory.auth({ apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] } });
const apiKey = factory.apiKey({
const auth = AuthFactory.from()
.apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] })
.build();
const apiKey = ApiKeyFactory.create({
userId: auth.user.id,
permissions: [Permission.AssetRead, Permission.AssetDelete],
});
@@ -158,10 +165,10 @@ describe(ApiKeyService.name, () => {
});
it('should allow adding new permissions', async () => {
const auth = factory.auth({
apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] },
});
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.AssetRead] });
const auth = AuthFactory.from()
.apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] })
.build();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead] });
mocks.apiKey.getById.mockResolvedValue(apiKey);
mocks.apiKey.update.mockResolvedValue(apiKey);
@@ -183,7 +190,7 @@ describe(ApiKeyService.name, () => {
describe('delete', () => {
it('should throw an error if the key is not found', async () => {
const auth = factory.auth();
const auth = AuthFactory.create();
const id = newUuid();
mocks.apiKey.getById.mockResolvedValue(void 0);
@@ -194,8 +201,8 @@ describe(ApiKeyService.name, () => {
});
it('should delete a key', async () => {
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
mocks.apiKey.getById.mockResolvedValue(apiKey);
mocks.apiKey.delete.mockResolvedValue();
@@ -208,8 +215,8 @@ describe(ApiKeyService.name, () => {
describe('getMine', () => {
it('should not work with a session token', async () => {
const session = factory.session();
const auth = factory.auth({ session });
const session = SessionFactory.create();
const auth = AuthFactory.from().session(session).build();
mocks.apiKey.getById.mockResolvedValue(void 0);
@@ -219,8 +226,8 @@ describe(ApiKeyService.name, () => {
});
it('should throw an error if the key is not found', async () => {
const apiKey = factory.authApiKey();
const auth = factory.auth({ apiKey });
const apiKey = ApiKeyFactory.create();
const auth = AuthFactory.from().apiKey(apiKey).build();
mocks.apiKey.getById.mockResolvedValue(void 0);
@@ -230,8 +237,8 @@ describe(ApiKeyService.name, () => {
});
it('should get a key by id', async () => {
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
mocks.apiKey.getById.mockResolvedValue(apiKey);
@@ -243,7 +250,7 @@ describe(ApiKeyService.name, () => {
describe('getById', () => {
it('should throw an error if the key is not found', async () => {
const auth = factory.auth();
const auth = AuthFactory.create();
const id = newUuid();
mocks.apiKey.getById.mockResolvedValue(void 0);
@@ -254,8 +261,8 @@ describe(ApiKeyService.name, () => {
});
it('should get a key by id', async () => {
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
mocks.apiKey.getById.mockResolvedValue(apiKey);
@@ -267,8 +274,8 @@ describe(ApiKeyService.name, () => {
describe('getAll', () => {
it('should return all the keys for a user', async () => {
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
mocks.apiKey.getByUserId.mockResolvedValue([apiKey]);

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotAcceptableException } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { NextFunction, Request, Response } from 'express';
import { readFileSync } from 'node:fs';
@@ -72,6 +72,13 @@ export class ApiService {
return next();
}
const responseType = request.accepts('text/html');
if (!responseType) {
throw new NotAcceptableException(
`The route ${request.path} was requested as ${request.header('accept')}, but only returns text/html`,
);
}
let status = 200;
let html = index;
@@ -105,7 +112,7 @@ export class ApiService {
html = render(index, meta);
}
res.status(status).type('text/html').header('Cache-Control', 'no-store').send(html);
res.status(status).type(responseType).header('Cache-Control', 'no-store').send(html);
};
}
}

View File

@@ -163,7 +163,6 @@ const assetEntity = Object.freeze({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
isFavorite: false,
encodedVideoPath: '',
duration: '0:00:00.000000',
files: [] as AssetFile[],
exifInfo: {
@@ -711,13 +710,18 @@ describe(AssetMediaService.name, () => {
});
it('should return the encoded video path if available', async () => {
const asset = AssetFactory.create({ encodedVideoPath: '/path/to/encoded/video.mp4' });
const asset = AssetFactory.from()
.file({ type: AssetFileType.EncodedVideo, path: '/path/to/encoded/video.mp4' })
.build();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getForVideo.mockResolvedValue(asset);
mocks.asset.getForVideo.mockResolvedValue({
originalPath: asset.originalPath,
encodedVideoPath: asset.files[0].path,
});
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
new ImmichFileResponse({
path: asset.encodedVideoPath!,
path: '/path/to/encoded/video.mp4',
cacheControl: CacheControl.PrivateWithCache,
contentType: 'video/mp4',
}),
@@ -727,7 +731,10 @@ describe(AssetMediaService.name, () => {
it('should fall back to the original path', async () => {
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getForVideo.mockResolvedValue(asset);
mocks.asset.getForVideo.mockResolvedValue({
originalPath: asset.originalPath,
encodedVideoPath: null,
});
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
new ImmichFileResponse({

View File

@@ -2,7 +2,6 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { Asset } from 'src/database';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
@@ -15,22 +14,13 @@ import {
AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaReplaceDto,
AssetMediaSize,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFileType,
AssetStatus,
AssetVisibility,
CacheControl,
JobName,
Permission,
StorageFolder,
} from 'src/enum';
import { AssetFileType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { BaseService } from 'src/services/base.service';
import { UploadFile, UploadRequest } from 'src/types';
@@ -151,6 +141,10 @@ export class AssetMediaService extends BaseService {
}
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
if (auth.sharedLink) {
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]);
}
await this.userRepository.updateUsage(auth.user.id, file.size);
return { id: asset.id, status: AssetMediaStatus.CREATED };
@@ -159,40 +153,6 @@ export class AssetMediaService extends BaseService {
}
}
async replaceAsset(
auth: AuthDto,
id: string,
dto: AssetMediaReplaceDto,
file: UploadFile,
sidecarFile?: UploadFile,
): Promise<AssetMediaResponseDto> {
try {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
const asset = await this.assetRepository.getById(id);
if (!asset) {
throw new Error('Asset not found');
}
this.requireQuota(auth, file.size);
await this.replaceFileData(asset.id, dto, file, sidecarFile?.originalPath);
// Next, create a backup copy of the existing record. The db record has already been updated above,
// but the local variable holds the original file data paths.
const copiedPhoto = await this.createCopy(asset);
// and immediate trash it
await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.Trashed });
await this.eventRepository.emit('AssetTrash', { assetId: copiedPhoto.id, userId: auth.user.id });
await this.userRepository.updateUsage(auth.user.id, file.size);
return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id };
} catch (error: any) {
return this.handleUploadError(error, auth, file, sidecarFile);
}
}
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
@@ -341,6 +301,11 @@ export class AssetMediaService extends BaseService {
this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException();
}
if (auth.sharedLink) {
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]);
}
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
}
@@ -348,82 +313,6 @@ export class AssetMediaService extends BaseService {
throw error;
}
/**
* Updates the specified assetId to the specified photo data file properties: checksum, path,
* timestamps, deviceIds, and sidecar. Derived properties like: faces, smart search info, etc
* are UNTOUCHED. The photo data files modification times on the filesysytem are updated to
* the specified timestamps. The exif db record is upserted, and then A METADATA_EXTRACTION
* job is queued to update these derived properties.
*/
private async replaceFileData(
assetId: string,
dto: AssetMediaReplaceDto,
file: UploadFile,
sidecarPath?: string,
): Promise<void> {
await this.assetRepository.update({
id: assetId,
checksum: file.checksum,
originalPath: file.originalPath,
type: mimeTypes.assetType(file.originalPath),
originalFileName: file.originalName,
deviceAssetId: dto.deviceAssetId,
deviceId: dto.deviceId,
fileCreatedAt: dto.fileCreatedAt,
fileModifiedAt: dto.fileModifiedAt,
localDateTime: dto.fileCreatedAt,
duration: dto.duration || null,
livePhotoVideoId: null,
});
await (sidecarPath
? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath })
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif(
{ assetId, fileSizeInByte: file.size },
{ lockedPropertiesBehavior: 'override' },
);
await this.jobRepository.queue({
name: JobName.AssetExtractMetadata,
data: { id: assetId, source: 'upload' },
});
}
/**
* Create a 'shallow' copy of the specified asset record creating a new asset record in the database.
* Uses only vital properties excluding things like: stacks, faces, smart search info, etc,
* and then queues a METADATA_EXTRACTION job.
*/
private async createCopy(asset: Omit<Asset, 'id'>) {
const created = await this.assetRepository.create({
ownerId: asset.ownerId,
originalPath: asset.originalPath,
originalFileName: asset.originalFileName,
libraryId: asset.libraryId,
deviceAssetId: asset.deviceAssetId,
deviceId: asset.deviceId,
type: asset.type,
checksum: asset.checksum,
fileCreatedAt: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
fileModifiedAt: asset.fileModifiedAt,
livePhotoVideoId: asset.livePhotoVideoId,
});
const { size } = await this.storageRepository.stat(created.originalPath);
await this.assetRepository.upsertExif(
{ assetId: created.id, fileSizeInByte: size },
{ lockedPropertiesBehavior: 'override' },
);
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } });
return created;
}
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
const asset = await this.assetRepository.create({
ownerId,

View File

@@ -7,6 +7,7 @@ import { AssetStats } from 'src/repositories/asset.repository';
import { AssetService } from 'src/services/asset.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { PartnerFactory } from 'test/factories/partner.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForAsset, getForAssetDeletion, getForPartner } from 'test/mappers';
import { factory, newUuid } from 'test/small.factory';
@@ -80,8 +81,8 @@ describe(AssetService.name, () => {
});
it('should not include partner assets if not in timeline', async () => {
const partner = factory.partner({ inTimeline: false });
const auth = factory.auth({ user: { id: partner.sharedWithId } });
const partner = PartnerFactory.create({ inTimeline: false });
const auth = AuthFactory.create({ id: partner.sharedWithId });
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
@@ -92,8 +93,8 @@ describe(AssetService.name, () => {
});
it('should include partner assets if in timeline', async () => {
const partner = factory.partner({ inTimeline: true });
const auth = factory.auth({ user: { id: partner.sharedWithId } });
const partner = PartnerFactory.create({ inTimeline: true });
const auth = AuthFactory.create({ id: partner.sharedWithId });
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);

View File

@@ -370,7 +370,7 @@ export class AssetService extends BaseService {
assetFiles.editedFullsizeFile?.path,
assetFiles.editedPreviewFile?.path,
assetFiles.editedThumbnailFile?.path,
asset.encodedVideoPath,
assetFiles.encodedVideoFile?.path,
];
if (deleteOnDisk && !asset.isOffline) {

View File

@@ -6,9 +6,13 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { AuthType, Permission } from 'src/enum';
import { AuthService } from 'src/services/auth.service';
import { UserMetadataItem } from 'src/types';
import { ApiKeyFactory } from 'test/factories/api-key.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { SessionFactory } from 'test/factories/session.factory';
import { UserFactory } from 'test/factories/user.factory';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { factory, newUuid } from 'test/small.factory';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
const oauthResponse = ({
@@ -91,8 +95,8 @@ describe(AuthService.name, () => {
});
it('should successfully log the user in', async () => {
const user = { ...(factory.user() as UserAdmin), password: 'immich_password' };
const session = factory.session();
const user = UserFactory.create({ password: 'immich_password' });
const session = SessionFactory.create();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
@@ -113,8 +117,8 @@ describe(AuthService.name, () => {
describe('changePassword', () => {
it('should change the password', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ user });
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
@@ -132,8 +136,8 @@ describe(AuthService.name, () => {
});
it('should throw when password does not match existing password', async () => {
const user = factory.user();
const auth = factory.auth({ user });
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.crypto.compareBcrypt.mockReturnValue(false);
@@ -144,8 +148,8 @@ describe(AuthService.name, () => {
});
it('should throw when user does not have a password', async () => {
const user = factory.user();
const auth = factory.auth({ user });
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: '' });
@@ -154,8 +158,8 @@ describe(AuthService.name, () => {
});
it('should change the password and logout other sessions', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ user });
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const dto = { password: 'old-password', newPassword: 'new-password', invalidateSessions: true };
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
@@ -175,7 +179,7 @@ describe(AuthService.name, () => {
describe('logout', () => {
it('should return the end session endpoint', async () => {
const auth = factory.auth();
const auth = AuthFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
@@ -186,7 +190,7 @@ describe(AuthService.name, () => {
});
it('should return the default redirect', async () => {
const auth = factory.auth();
const auth = AuthFactory.create();
await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({
successful: true,
@@ -262,11 +266,11 @@ describe(AuthService.name, () => {
});
it('should validate using authorization header', async () => {
const session = factory.session();
const session = SessionFactory.create();
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
user: UserFactory.create(),
pinExpiresAt: null,
appVersion: null,
};
@@ -340,7 +344,7 @@ describe(AuthService.name, () => {
});
it('should accept a base64url key', async () => {
const user = factory.userAdmin();
const user = UserFactory.create();
const sharedLink = { ...sharedLinkStub.valid, user } as any;
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
@@ -361,7 +365,7 @@ describe(AuthService.name, () => {
});
it('should accept a hex key', async () => {
const user = factory.userAdmin();
const user = UserFactory.create();
const sharedLink = { ...sharedLinkStub.valid, user } as any;
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
@@ -396,7 +400,7 @@ describe(AuthService.name, () => {
});
it('should accept a valid slug', async () => {
const user = factory.userAdmin();
const user = UserFactory.create();
const sharedLink = { ...sharedLinkStub.valid, slug: 'slug-123', user } as any;
mocks.sharedLink.getBySlug.mockResolvedValue(sharedLink);
@@ -428,11 +432,11 @@ describe(AuthService.name, () => {
});
it('should return an auth dto', async () => {
const session = factory.session();
const session = SessionFactory.create();
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
user: UserFactory.create(),
pinExpiresAt: null,
appVersion: null,
};
@@ -455,11 +459,11 @@ describe(AuthService.name, () => {
});
it('should throw if admin route and not an admin', async () => {
const session = factory.session();
const session = SessionFactory.create();
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
user: UserFactory.create(),
isPendingSyncReset: false,
pinExpiresAt: null,
appVersion: null,
@@ -477,11 +481,11 @@ describe(AuthService.name, () => {
});
it('should update when access time exceeds an hour', async () => {
const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() });
const session = SessionFactory.create({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() });
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
user: UserFactory.create(),
isPendingSyncReset: false,
pinExpiresAt: null,
appVersion: null,
@@ -517,8 +521,8 @@ describe(AuthService.name, () => {
});
it('should throw an error if api key has insufficient permissions', async () => {
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [] });
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.create({ permissions: [] });
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
@@ -533,8 +537,8 @@ describe(AuthService.name, () => {
});
it('should default to requiring the all permission when omitted', async () => {
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [Permission.AssetRead] });
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.create({ permissions: [Permission.AssetRead] });
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
@@ -548,10 +552,12 @@ describe(AuthService.name, () => {
});
it('should not require any permission when metadata is set to `false`', async () => {
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] });
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.from({ permissions: [Permission.ActivityRead] })
.user(authUser)
.build();
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
mocks.apiKey.getKey.mockResolvedValue(authApiKey);
const result = sut.authenticate({
headers: { 'x-api-key': 'auth_token' },
@@ -562,10 +568,12 @@ describe(AuthService.name, () => {
});
it('should return an auth dto', async () => {
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [Permission.All] });
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.from({ permissions: [Permission.All] })
.user(authUser)
.build();
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
mocks.apiKey.getKey.mockResolvedValue(authApiKey);
await expect(
sut.authenticate({
@@ -629,12 +637,12 @@ describe(AuthService.name, () => {
});
it('should link an existing user', async () => {
const user = factory.userAdmin();
const user = UserFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
@@ -649,7 +657,7 @@ describe(AuthService.name, () => {
});
it('should not link to a user with a different oauth sub', async () => {
const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' });
const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.user.getByEmail.mockResolvedValueOnce(user);
@@ -669,13 +677,13 @@ describe(AuthService.name, () => {
});
it('should allow auto registering by default', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
@@ -690,13 +698,13 @@ describe(AuthService.name, () => {
});
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
const user = factory.userAdmin({ isAdmin: true });
const user = UserFactory.create({ isAdmin: true });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
await expect(
@@ -717,11 +725,11 @@ describe(AuthService.name, () => {
'app.immich:///oauth-callback?code=abc123',
]) {
it(`should use the mobile redirect override for a url of ${url}`, async () => {
const user = factory.userAdmin();
const user = UserFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
@@ -735,13 +743,13 @@ describe(AuthService.name, () => {
}
it('should use the default quota', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
@@ -755,14 +763,14 @@ describe(AuthService.name, () => {
});
it('should ignore an invalid storage quota', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' });
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
@@ -776,14 +784,14 @@ describe(AuthService.name, () => {
});
it('should ignore a negative quota', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 });
mocks.user.getAdmin.mockResolvedValue(user);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
@@ -797,14 +805,14 @@ describe(AuthService.name, () => {
});
it('should set quota for 0 quota', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 });
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
@@ -825,15 +833,15 @@ describe(AuthService.name, () => {
});
it('should use a valid storage quota', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
@@ -855,7 +863,7 @@ describe(AuthService.name, () => {
it('should sync the profile picture', async () => {
const fileId = newUuid();
const user = factory.userAdmin({ oauthId: 'oauth-id' });
const user = UserFactory.create({ oauthId: 'oauth-id' });
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
@@ -871,7 +879,7 @@ describe(AuthService.name, () => {
data: new Uint8Array([1, 2, 3, 4, 5]).buffer,
});
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
@@ -889,7 +897,7 @@ describe(AuthService.name, () => {
});
it('should not sync the profile picture if the user already has one', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue({
@@ -899,7 +907,7 @@ describe(AuthService.name, () => {
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
@@ -914,15 +922,15 @@ describe(AuthService.name, () => {
});
it('should only allow "admin" and "user" for the role claim', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
@@ -943,14 +951,14 @@ describe(AuthService.name, () => {
});
it('should create an admin user if the role claim is set to admin', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
@@ -971,7 +979,7 @@ describe(AuthService.name, () => {
});
it('should accept a custom role claim', async () => {
const user = factory.userAdmin({ oauthId: 'oauth-id' });
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue({
oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' },
@@ -980,7 +988,7 @@ describe(AuthService.name, () => {
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(factory.session());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
@@ -1003,8 +1011,8 @@ describe(AuthService.name, () => {
describe('link', () => {
it('should link an account', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ apiKey: { permissions: [] }, user });
const user = UserFactory.create();
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.update.mockResolvedValue(user);
@@ -1019,8 +1027,8 @@ describe(AuthService.name, () => {
});
it('should not link an already linked oauth.sub', async () => {
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [] });
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.create({ permissions: [] });
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
@@ -1036,8 +1044,8 @@ describe(AuthService.name, () => {
describe('unlink', () => {
it('should unlink an account', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ user, apiKey: { permissions: [] } });
const user = UserFactory.create();
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.update.mockResolvedValue(user);
@@ -1050,8 +1058,8 @@ describe(AuthService.name, () => {
describe('setupPinCode', () => {
it('should setup a PIN code', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ user });
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const dto = { pinCode: '123456' };
mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' });
@@ -1065,8 +1073,8 @@ describe(AuthService.name, () => {
});
it('should fail if the user already has a PIN code', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ user });
const user = UserFactory.create();
const auth = AuthFactory.create(user);
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
@@ -1076,8 +1084,8 @@ describe(AuthService.name, () => {
describe('changePinCode', () => {
it('should change the PIN code', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ user });
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const dto = { pinCode: '123456', newPinCode: '012345' };
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
@@ -1091,37 +1099,37 @@ describe(AuthService.name, () => {
});
it('should fail if the PIN code does not match', async () => {
const user = factory.userAdmin();
const user = UserFactory.create();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
await expect(
sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }),
sut.changePinCode(AuthFactory.create(user), { pinCode: '000000', newPinCode: '012345' }),
).rejects.toThrow('Wrong PIN code');
});
});
describe('resetPinCode', () => {
it('should reset the PIN code', async () => {
const currentSession = factory.session();
const user = factory.userAdmin();
const currentSession = SessionFactory.create();
const user = UserFactory.create();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
mocks.session.lockAll.mockResolvedValue(void 0);
mocks.session.update.mockResolvedValue(currentSession);
await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
await sut.resetPinCode(AuthFactory.create(user), { pinCode: '123456' });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id);
});
it('should throw if the PIN code does not match', async () => {
const user = factory.userAdmin();
const user = UserFactory.create();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
await expect(sut.resetPinCode(AuthFactory.create(user), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
});
});
});

View File

@@ -1,7 +1,7 @@
import { jwtVerify } from 'jose';
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { CliService } from 'src/services/cli.service';
import { factory } from 'test/small.factory';
import { UserFactory } from 'test/factories/user.factory';
import { newTestService, ServiceMocks } from 'test/utils';
import { describe, it } from 'vitest';
@@ -15,7 +15,7 @@ describe(CliService.name, () => {
describe('listUsers', () => {
it('should list users', async () => {
mocks.user.getList.mockResolvedValue([factory.userAdmin({ isAdmin: true })]);
mocks.user.getList.mockResolvedValue([UserFactory.create({ isAdmin: true })]);
await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]);
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true });
});
@@ -32,10 +32,10 @@ describe(CliService.name, () => {
});
it('should default to a random password', async () => {
const admin = factory.userAdmin({ isAdmin: true });
const admin = UserFactory.create({ isAdmin: true });
mocks.user.getAdmin.mockResolvedValue(admin);
mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true }));
const ask = vitest.fn().mockImplementation(() => {});
@@ -50,7 +50,7 @@ describe(CliService.name, () => {
});
it('should use the supplied password', async () => {
const admin = factory.userAdmin({ isAdmin: true });
const admin = UserFactory.create({ isAdmin: true });
mocks.user.getAdmin.mockResolvedValue(admin);
mocks.user.update.mockResolvedValue(admin);

View File

@@ -2,9 +2,9 @@ import { MapService } from 'src/services/map.service';
import { AlbumFactory } from 'test/factories/album.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { PartnerFactory } from 'test/factories/partner.factory';
import { userStub } from 'test/fixtures/user.stub';
import { getForAlbum, getForPartner } from 'test/mappers';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe(MapService.name, () => {
@@ -40,7 +40,7 @@ describe(MapService.name, () => {
it('should include partner assets', async () => {
const auth = AuthFactory.create();
const partner = factory.partner({ sharedWithId: auth.user.id });
const partner = PartnerFactory.create({ sharedWithId: auth.user.id });
const asset = AssetFactory.from()
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })

View File

@@ -2254,7 +2254,9 @@ describe(MediaService.name, () => {
});
it('should delete existing transcode if current policy does not require transcoding', async () => {
const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' });
const asset = AssetFactory.from({ type: AssetType.Video })
.file({ type: AssetFileType.EncodedVideo, path: '/encoded/video/path.mp4' })
.build();
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } });
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
@@ -2264,7 +2266,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).not.toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: { files: [asset.encodedVideoPath] },
data: { files: ['/encoded/video/path.mp4'] },
});
});

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