Compare commits

...

19 Commits

Author SHA1 Message Date
midzelis 73f53935ca feat: make CI more parallel
Change-Id: I97e07aec57b0ca0626591033bd2b6da26a6a6964
2026-03-21 04:35:53 +00:00
Luis Nachtigall 00dae6ac38 fix(mobile): cronet image cache clearing on android (#27054) 2026-03-20 18:28:24 -04:00
Daniel Dietzler 5a8fd40dc5 fix: svelte reactivity issues (#27109) 2026-03-20 15:56:16 -04:00
Jason Rasmussen 813d684aaa fix: shared link add to album (#27063) 2026-03-20 13:14:07 -05:00
Luis Nachtigall 644f705be1 fix(mobile): correct maximumSizeBytes setter to use maximumSizeBytes property (#27098)
fix: correct maximumSizeBytes setter to use maximumSizeBytes property
2026-03-20 12:22:59 -04:00
Thomas f3e4bcc733 fix(server): queue version check job when config changed (#27094)
Nothing happens when the version checks are enabled, which means the
server may return very stale versions until the next job runs.

Refs: #26935
2026-03-20 10:07:40 -05:00
Michel Heusschen 9a0c17fdb8 fix(web): preserve album scroll when adding to other albums (#27078) 2026-03-20 09:42:19 -05:00
Michel Heusschen b7c4497dfd fix(web): allow showing combobox items outside modals (#27075)
fix(web): allow showing combobox items outside of modals
2026-03-20 09:41:34 -05:00
Yaros 9c227aeaf5 fix(mobile): simplified chinese not available (#27066) 2026-03-20 00:27:50 -05:00
github-actions e939fde6f1 chore: version v2.6.1 2026-03-19 17:56:42 +00:00
Mert 019beaed0b fix(mobile): server url migration (#27050)
decode json
2026-03-19 12:52:51 -05:00
Jason Rasmussen 0e4d6d4eac fix(web): disable send button (#27051)
fix(web): disable button while sending
2026-03-19 17:39:13 +00:00
Daniel Dietzler 79f978ddeb fix: writing empty exif tags (#27025) 2026-03-19 17:17:56 +00:00
Jason Rasmussen f2445ecab1 fix(web): stop in-progress uploads on logout (#27021) 2026-03-19 13:05:11 -04:00
Jason Rasmussen 86311e3cfe fix(web): wrap long album title (#27012) 2026-03-19 13:04:56 -04:00
Jason Rasmussen 29000461c2 fix: ignore errors deleting untitled album (#27020) 2026-03-19 12:55:43 -04:00
Mees Frensel b30373b24f fix: release rootles compose file (#27048) 2026-03-19 16:45:11 +00:00
bo0tzz bc2439883a fix: track PR template auto-close with label (#27042)
* fix: track PR template auto-close with label

* fix: format
2026-03-19 12:42:29 -04:00
Jason Rasmussen 044257531e fix(server): fallback to email when name is empty (#27016) 2026-03-19 12:41:20 -04:00
57 changed files with 1012 additions and 554 deletions
+21 -4
View File
@@ -6,6 +6,9 @@ on:
permissions: {}
env:
LABEL_ID: 'LA_kwDOGyI-8M8AAAACcAeOfg' # auto-closed:template
jobs:
parse:
runs-on: ubuntu-latest
@@ -46,9 +49,10 @@ jobs:
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f labelId="$LABEL_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!) {
mutation CommentAndClosePR($prId: ID!, $body: String!, $labelId: ID!) {
addComment(input: {
subjectId: $prId,
body: $body
@@ -60,21 +64,34 @@ jobs:
}) {
__typename
}
addLabelsToLabelable(input: {
labelableId: $prId,
labelIds: [$labelId]
}) {
__typename
}
}'
- name: Reopen PR (sections now present, PR closed)
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
- name: Reopen PR (sections now present, PR was auto-closed)
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' && contains(github.event.pull_request.labels.*.node_id, env.LABEL_ID) }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f labelId="$LABEL_ID" \
-f query='
mutation ReopenPR($prId: ID!) {
mutation ReopenPR($prId: ID!, $labelId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
removeLabelsFromLabelable(input: {
labelableId: $prId,
labelIds: [$labelId]
}) {
__typename
}
}'
+1
View File
@@ -151,6 +151,7 @@ jobs:
body_path: misc/release/notes.tmpl
files: |
docker/docker-compose.yml
docker/docker-compose.rootless.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
+207 -100
View File
@@ -8,6 +8,99 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
x-e2e-server-job: &e2e-server-job
needs: [pre-job, build-e2e-images]
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).server == true || fromJSON(needs.pre-job.outputs.should_run).cli == true }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
defaults:
run:
working-directory: ./e2e
strategy:
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
x-e2e-web-job: &e2e-web-job
needs: [pre-job, build-e2e-images]
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
defaults:
run:
working-directory: ./e2e
strategy:
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
x-e2e-steps:
token: &e2e-token-step
id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
checkout: &e2e-checkout-step
name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
setup-pnpm: &e2e-setup-pnpm-step
name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
setup-node: &e2e-setup-node-step
name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
setup-sdk: &e2e-setup-sdk-step
name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
setup-web: &e2e-setup-web-step
name: Run setup web
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
working-directory: ./web
if: ${{ !cancelled() }}
setup-cli: &e2e-setup-cli-step
name: Run setup cli
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./cli
if: ${{ !cancelled() }}
install-deps: &e2e-install-deps-step
name: Install dependencies
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
install-playwright: &e2e-install-playwright-step
name: Install Playwright Browsers
run: pnpm exec playwright install chromium --only-shell
if: ${{ !cancelled() }}
download-images: &e2e-download-images-step
name: Download Docker images
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: docker-images-${{ matrix.runner }}
path: e2e
load-images: &e2e-load-images-step
name: Load Docker images
run: docker load < docker-images.tar.gz
docker-compose-up: &e2e-docker-compose-up-step
name: Start Docker Compose
run: docker compose up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }}
capture-docker-logs: &e2e-capture-docker-logs-step
name: Capture Docker logs
if: always()
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
jobs:
pre-job:
runs-on: ubuntu-latest
@@ -397,10 +490,10 @@ jobs:
- name: Run medium tests
run: pnpm test:medium
if: ${{ !cancelled() }}
e2e-tests-server-cli:
name: End-to-End Tests (Server & CLI)
build-e2e-images:
name: Build E2E Docker Images
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).server == true || fromJSON(needs.pre-job.outputs.should_run).cli == true }}
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).server == true || fromJSON(needs.pre-job.outputs.should_run).cli == true || fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
@@ -411,111 +504,86 @@ jobs:
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
- *e2e-token-step
- *e2e-checkout-step
- name: Build Docker images
run: docker compose build
- name: Save Docker images
run: docker save immich-server:latest immich-e2e-e2e-auth-server | gzip > docker-images.tar.gz
- name: Upload Docker images
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Run setup web
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
working-directory: ./web
if: ${{ !cancelled() }}
- name: Run setup cli
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./cli
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Start Docker Compose
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }}
name: docker-images-${{ matrix.runner }}
path: e2e/docker-images.tar.gz
retention-days: 1
e2e-tests-server-cli:
<<: *e2e-server-job
name: End-to-End Tests (Server & CLI)
steps:
- *e2e-token-step
- *e2e-checkout-step
- *e2e-setup-pnpm-step
- *e2e-setup-node-step
- *e2e-setup-sdk-step
- *e2e-setup-web-step
- *e2e-setup-cli-step
- *e2e-install-deps-step
- *e2e-download-images-step
- *e2e-load-images-step
- *e2e-docker-compose-up-step
- name: Run e2e tests (api & cli)
env:
VITEST_DISABLE_DOCKER_SETUP: true
run: pnpm test
if: ${{ !cancelled() }}
- name: Run e2e tests (maintenance)
env:
VITEST_DISABLE_DOCKER_SETUP: true
run: pnpm test:maintenance
if: ${{ !cancelled() }}
- name: Capture Docker logs
if: always()
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- *e2e-capture-docker-logs-step
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: e2e-server-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
e2e-tests-web:
name: End-to-End Tests (Web)
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).web == true }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
defaults:
run:
working-directory: ./e2e
strategy:
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
e2e-tests-server-maintenance:
<<: *e2e-server-job
name: End-to-End Tests (Server Maintenance)
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
- *e2e-token-step
- *e2e-checkout-step
- *e2e-setup-pnpm-step
- *e2e-setup-node-step
- *e2e-setup-sdk-step
- *e2e-setup-web-step
- *e2e-setup-cli-step
- *e2e-install-deps-step
- *e2e-download-images-step
- *e2e-load-images-step
- *e2e-docker-compose-up-step
- name: Run e2e tests (maintenance)
env:
VITEST_DISABLE_DOCKER_SETUP: true
run: pnpm test:maintenance
if: ${{ !cancelled() }}
- *e2e-capture-docker-logs-step
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
run: pnpm exec playwright install chromium --only-shell
if: ${{ !cancelled() }}
- name: Docker build
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }}
name: e2e-server-maintenance-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
e2e-tests-web:
<<: *e2e-web-job
name: End-to-End Tests (Web)
steps:
- *e2e-token-step
- *e2e-checkout-step
- *e2e-setup-pnpm-step
- *e2e-setup-node-step
- *e2e-setup-sdk-step
- *e2e-install-deps-step
- *e2e-install-playwright-step
- *e2e-download-images-step
- *e2e-load-images-step
- *e2e-docker-compose-up-step
- name: Run e2e tests (web)
env:
PLAYWRIGHT_DISABLE_WEBSERVER: true
@@ -527,6 +595,27 @@ jobs:
with:
name: e2e-web-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- *e2e-capture-docker-logs-step
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: e2e-web-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
e2e-tests-web-ui:
<<: *e2e-web-job
name: End-to-End Tests (Web UI)
steps:
- *e2e-token-step
- *e2e-checkout-step
- *e2e-setup-pnpm-step
- *e2e-setup-node-step
- *e2e-setup-sdk-step
- *e2e-install-deps-step
- *e2e-install-playwright-step
- *e2e-download-images-step
- *e2e-load-images-step
- *e2e-docker-compose-up-step
- name: Run ui tests (web)
env:
PLAYWRIGHT_DISABLE_WEBSERVER: true
@@ -538,6 +627,27 @@ jobs:
with:
name: e2e-ui-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- *e2e-capture-docker-logs-step
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: e2e-web-ui-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
e2e-tests-web-maintenance:
<<: *e2e-web-job
name: End-to-End Tests (Web Maintenance)
steps:
- *e2e-token-step
- *e2e-checkout-step
- *e2e-setup-pnpm-step
- *e2e-setup-node-step
- *e2e-setup-sdk-step
- *e2e-install-deps-step
- *e2e-install-playwright-step
- *e2e-download-images-step
- *e2e-load-images-step
- *e2e-docker-compose-up-step
- name: Run maintenance tests
env:
PLAYWRIGHT_DISABLE_WEBSERVER: true
@@ -549,19 +659,16 @@ jobs:
with:
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- name: Capture Docker logs
if: always()
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- *e2e-capture-docker-logs-step
- name: Archive Docker logs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: e2e-web-docker-logs-${{ matrix.runner }}
name: e2e-web-maintenance-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
success-check-e2e:
name: End-to-End Tests Success
needs: [e2e-tests-server-cli, e2e-tests-web]
needs: [e2e-tests-server-cli, e2e-tests-server-maintenance, e2e-tests-web, e2e-tests-web-ui, e2e-tests-web-maintenance]
permissions: {}
runs-on: ubuntu-latest
if: always()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.6.0",
"version": "2.6.1",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
+2 -2
View File
@@ -1,7 +1,7 @@
[
{
"label": "v2.6.0",
"url": "https://docs.v2.6.0.archive.immich.app"
"label": "v2.6.1",
"url": "https://docs.v2.6.1.archive.immich.app"
},
{
"label": "v2.5.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.6.0",
"version": "2.6.1",
"description": "",
"main": "index.js",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-i18n",
"version": "2.6.0",
"version": "2.6.1",
"private": true,
"scripts": {
"format": "prettier --cache --check .",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.6.0"
version = "2.6.1"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -898,7 +898,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "2.6.0"
version = "2.6.1"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },
@@ -23,10 +23,18 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.io.File
import java.io.IOException
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.net.Authenticator
import java.net.CookieHandler
import java.net.PasswordAuthentication
@@ -277,10 +285,13 @@ object HttpClientManager {
return result
}
fun rebuildCronetEngine(): CronetEngine {
val old = cronetEngine!!
cronetEngine = buildCronetEngine()
return old
suspend fun rebuildCronetEngine(): Result<Long> {
return runCatching {
cronetEngine?.shutdown()
val deletionResult = deleteFolderAndGetSize(cronetStoragePath.toPath())
cronetEngine = buildCronetEngine()
deletionResult
}
}
val cronetStoragePath: File get() = cronetStorageDir
@@ -301,7 +312,7 @@ object HttpClientManager {
}
}
private fun buildCronetEngine(): CronetEngine {
fun buildCronetEngine(): CronetEngine {
return CronetEngine.Builder(appContext)
.enableHttp2(true)
.enableQuic(true)
@@ -312,6 +323,27 @@ object HttpClientManager {
.build()
}
private suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
totalSize += attrs.size()
Files.delete(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (dir != root) {
Files.delete(dir)
}
return FileVisitResult.CONTINUE
}
})
totalSize
}
private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
@@ -21,11 +21,6 @@ import java.io.EOFException
import java.io.File
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap
private class RemoteRequest(val cancellationSignal: CancellationSignal)
@@ -205,18 +200,15 @@ private class CronetImageFetcher : ImageFetcher {
private fun onDrained() {
val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared
val onCacheCleared = this.onCacheCleared
this.onCacheCleared = null
onCacheCleared
}
if (onCacheCleared != null) {
val oldEngine = HttpClientManager.rebuildCronetEngine()
oldEngine.shutdown()
CoroutineScope(Dispatchers.IO).launch {
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
synchronized(stateLock) { draining = false }
onCacheCleared(result)
}
} ?: return
CoroutineScope(Dispatchers.IO).launch {
val result = HttpClientManager.rebuildCronetEngine()
synchronized(stateLock) { draining = false }
onCacheCleared(result)
}
}
@@ -306,26 +298,6 @@ private class CronetImageFetcher : ImageFetcher {
}
}
suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
totalSize += attrs.size()
Files.delete(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (dir != root) {
Files.delete(dir)
}
return FileVisitResult.CONTINUE
}
})
totalSize
}
}
private class OkHttpImageFetcher private constructor(
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3038,
"android.injected.version.name" => "2.6.0",
"android.injected.version.code" => 3039,
"android.injected.version.name" => "2.6.1",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.6.0</string>
<string>2.6.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
+1 -1
View File
@@ -7,7 +7,7 @@ const Map<String, Locale> locales = {
'Arabic (ar)': Locale('ar'),
'Bulgarian (bg)': Locale('bg'),
'Catalan (ca)': Locale('ca'),
'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'SIMPLIFIED'),
'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'),
'Chinese Traditional (zh_TW)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
'Croatian (hr)': Locale('hr'),
'Czech (cs)': Locale('cs'),
+3 -2
View File
@@ -6,6 +6,7 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
@@ -184,8 +185,8 @@ class ApiService {
if (externalJson != null) {
final List<dynamic> list = jsonDecode(externalJson);
for (final entry in list) {
final url = entry['url'] as String?;
if (url != null && url.isNotEmpty) urls.add(url);
final url = AuxilaryEndpoint.fromJson(entry).url;
if (url.isNotEmpty) urls.add(url);
}
}
return urls;
+1 -1
View File
@@ -20,7 +20,7 @@ final class CustomImageCache implements ImageCache {
set maximumSize(int value) => _small.maximumSize = value;
@override
set maximumSizeBytes(int value) => _small.maximumSize = value;
set maximumSizeBytes(int value) => _small.maximumSizeBytes = value;
@override
void clear() {
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.6.0
- API version: 2.6.1
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.6.0+3038
version: 2.6.1+3039
environment:
sdk: '>=3.8.0 <4.0.0'
+1 -1
View File
@@ -15166,7 +15166,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.6.0",
"version": "2.6.1",
"contact": {}
},
"tags": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.6.0",
"version": "2.6.1",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 2.6.0
* 2.6.1
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "2.6.0",
"version": "2.6.1",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
+89 -84
View File
@@ -747,8 +747,8 @@ importers:
specifier: workspace:*
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.64.0
version: 0.64.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)
specifier: ^0.65.3
version: 0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.3.0
version: 0.3.0
@@ -781,7 +781,7 @@ importers:
version: 0.42.0
'@zoom-image/svelte':
specifier: ^0.3.0
version: 0.3.9(svelte@5.53.7)
version: 0.3.9(svelte@5.53.13)
dom-to-image:
specifier: ^2.6.0
version: 2.6.0
@@ -832,16 +832,16 @@ importers:
version: 5.2.2
svelte-i18n:
specifier: ^4.0.1
version: 4.0.1(svelte@5.53.7)
version: 4.0.1(svelte@5.53.13)
svelte-jsoneditor:
specifier: ^3.10.0
version: 3.11.0(svelte@5.53.7)
version: 3.11.0(svelte@5.53.13)
svelte-maplibre:
specifier: ^1.2.5
version: 1.2.6(svelte@5.53.7)
version: 1.2.6(svelte@5.53.13)
svelte-persisted-store:
specifier: ^0.12.0
version: 0.12.0(svelte@5.53.7)
version: 0.12.0(svelte@5.53.13)
tabbable:
specifier: ^6.2.0
version: 6.4.0
@@ -875,16 +875,16 @@ importers:
version: 3.1.2
'@sveltejs/adapter-static':
specifier: ^3.0.8
version: 3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
version: 3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
'@sveltejs/enhanced-img':
specifier: ^0.10.0
version: 0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/kit':
specifier: ^2.27.1
version: 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte':
specifier: 6.2.4
version: 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@tailwindcss/vite':
specifier: ^4.1.7
version: 4.2.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
@@ -893,7 +893,7 @@ importers:
version: 6.9.1
'@testing-library/svelte':
specifier: ^5.2.8
version: 5.3.1(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 5.3.1(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1)
@@ -932,7 +932,7 @@ importers:
version: 6.2.1(eslint@10.0.2(jiti@2.6.1))
eslint-plugin-svelte:
specifier: ^3.12.4
version: 3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.7)
version: 3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.13)
eslint-plugin-unicorn:
specifier: ^63.0.0
version: 63.0.0(eslint@10.0.2(jiti@2.6.1))
@@ -953,19 +953,19 @@ importers:
version: 4.2.0(prettier@3.8.1)
prettier-plugin-svelte:
specifier: ^3.3.3
version: 3.5.1(prettier@3.8.1)(svelte@5.53.7)
version: 3.5.1(prettier@3.8.1)(svelte@5.53.13)
rollup-plugin-visualizer:
specifier: ^6.0.0
version: 6.0.11(rollup@4.55.1)
svelte:
specifier: 5.53.7
version: 5.53.7
specifier: 5.53.13
version: 5.53.13
svelte-check:
specifier: ^4.1.5
version: 4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3)
version: 4.4.4(picomatch@4.0.3)(svelte@5.53.13)(typescript@5.9.3)
svelte-eslint-parser:
specifier: ^1.3.3
version: 1.6.0(svelte@5.53.7)
version: 1.6.0(svelte@5.53.13)
tailwindcss:
specifier: ^4.1.7
version: 4.2.1
@@ -3035,8 +3035,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.64.0':
resolution: {integrity: sha512-jbPN1x9KAAcW18h4RO7skbFYjkR4Lg+mEVjSDzsPC2NBNzSi4IA0PIHhFEwnD5dk4OS7+UjRG8m5/QTyotrm4A==}
'@immich/ui@0.65.3':
resolution: {integrity: sha512-jMXzCzMNTcCdWXt9IUP7GkALE5oEvPQk/jCOuI2bfxsxCZFzMkUfUS+AV83Vg1vQ6l+g39PbKSPKBEzv125ATQ==}
peerDependencies:
svelte: ^5.0.0
@@ -6752,6 +6752,9 @@ packages:
devalue@5.6.3:
resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==}
devalue@5.6.4:
resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
@@ -11218,8 +11221,8 @@ packages:
peerDependencies:
svelte: ^5.30.2
svelte@5.53.7:
resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==}
svelte@5.53.13:
resolution: {integrity: sha512-9P6I/jGcQMzAMb76Uyd6L6RELAC7qt53GOSBLCke9lubh9iJjmjCo+EffRH4gOPnTB/x4RR2Tmt6s3o9ywQO3g==}
engines: {node: '>=18'}
svg-parser@2.0.4:
@@ -14948,22 +14951,22 @@ snapshots:
pg-connection-string: 2.12.0
postgres: 3.4.8
'@immich/svelte-markdown-preprocess@0.2.1(svelte@5.53.7)':
'@immich/svelte-markdown-preprocess@0.2.1(svelte@5.53.13)':
dependencies:
front-matter: 4.0.2
marked: 17.0.3
node-emoji: 2.2.0
svelte: 5.53.7
svelte: 5.53.13
'@immich/ui@0.64.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)':
'@immich/ui@0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.7)
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.13)
'@internationalized/date': 3.10.0
'@mdi/js': 7.4.47
bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)
bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
luxon: 3.7.2
simple-icons: 16.9.0
svelte: 5.53.7
svelte: 5.53.13
svelte-highlight: 7.9.0
tailwind-merge: 3.5.0
tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1)
@@ -16300,17 +16303,17 @@ snapshots:
dependencies:
acorn: 8.16.0
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
dependencies:
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/enhanced-img@0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/enhanced-img@0.10.3(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
magic-string: 0.30.21
sharp: 0.34.5
svelte: 5.53.7
svelte-parse-markup: 0.1.5(svelte@5.53.7)
svelte: 5.53.13
svelte-parse-markup: 0.1.5(svelte@5.53.13)
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-imagetools: 9.0.3(rollup@4.55.1)
zimmerframe: 1.1.4
@@ -16318,11 +16321,11 @@ snapshots:
- rollup
- supports-color
'@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@types/cookie': 0.6.0
acorn: 8.16.0
cookie: 0.6.0
@@ -16333,28 +16336,28 @@ snapshots:
mrmime: 2.0.1
set-cookie-parser: 3.0.1
sirv: 3.0.2
svelte: 5.53.7
svelte: 5.53.13
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
optionalDependencies:
'@opentelemetry/api': 1.9.0
typescript: 5.9.3
'@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
debug: 4.4.3
svelte: 5.53.7
svelte: 5.53.13
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
deepmerge: 4.3.1
magic-string: 0.30.21
obug: 2.1.1
svelte: 5.53.7
svelte: 5.53.13
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitefu: 1.1.1(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
transitivePeerDependencies:
@@ -16602,15 +16605,15 @@ snapshots:
picocolors: 1.1.1
redent: 3.0.0
'@testing-library/svelte-core@1.0.0(svelte@5.53.7)':
'@testing-library/svelte-core@1.0.0(svelte@5.53.13)':
dependencies:
svelte: 5.53.7
svelte: 5.53.13
'@testing-library/svelte@5.3.1(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@testing-library/svelte@5.3.1(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/svelte-core': 1.0.0(svelte@5.53.7)
svelte: 5.53.7
'@testing-library/svelte-core': 1.0.0(svelte@5.53.13)
svelte: 5.53.13
optionalDependencies:
vite: 7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
@@ -17527,10 +17530,10 @@ snapshots:
dependencies:
'@namnode/store': 0.1.0
'@zoom-image/svelte@0.3.9(svelte@5.53.7)':
'@zoom-image/svelte@0.3.9(svelte@5.53.13)':
dependencies:
'@zoom-image/core': 0.42.0
svelte: 5.53.7
svelte: 5.53.13
abbrev@1.1.1: {}
@@ -17894,15 +17897,15 @@ snapshots:
binary-extensions@2.3.0: {}
bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7):
bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/dom': 1.7.4
'@internationalized/date': 3.10.0
esm-env: 1.2.2
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)
svelte: 5.53.7
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
svelte: 5.53.13
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
tabbable: 6.4.0
transitivePeerDependencies:
- '@sveltejs/kit'
@@ -18995,6 +18998,8 @@ snapshots:
devalue@5.6.3: {}
devalue@5.6.4: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
@@ -19403,7 +19408,7 @@ snapshots:
'@types/eslint': 9.6.1
eslint-config-prettier: 10.1.8(eslint@10.0.2(jiti@2.6.1))
eslint-plugin-svelte@3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.7):
eslint-plugin-svelte@3.15.0(eslint@10.0.2(jiti@2.6.1))(svelte@5.53.13):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1))
'@jridgewell/sourcemap-codec': 1.5.5
@@ -19415,9 +19420,9 @@ snapshots:
postcss-load-config: 3.1.4(postcss@8.5.8)
postcss-safe-parser: 7.0.1(postcss@8.5.8)
semver: 7.7.4
svelte-eslint-parser: 1.6.0(svelte@5.53.7)
svelte-eslint-parser: 1.6.0(svelte@5.53.13)
optionalDependencies:
svelte: 5.53.7
svelte: 5.53.13
transitivePeerDependencies:
- ts-node
@@ -23155,10 +23160,10 @@ snapshots:
dependencies:
prettier: 3.8.1
prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.7):
prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.13):
dependencies:
prettier: 3.8.1
svelte: 5.53.7
svelte: 5.53.13
prettier@3.8.1: {}
@@ -23763,14 +23768,14 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
runed@0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7):
runed@0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
dependencies:
dequal: 2.0.3
esm-env: 1.2.2
lz-string: 1.5.0
svelte: 5.53.7
svelte: 5.53.13
optionalDependencies:
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
rw@1.3.3: {}
@@ -24394,23 +24399,23 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svelte-awesome@3.3.5(svelte@5.53.7):
svelte-awesome@3.3.5(svelte@5.53.13):
dependencies:
svelte: 5.53.7
svelte: 5.53.13
svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.7)(typescript@5.9.3):
svelte-check@4.4.4(picomatch@4.0.3)(svelte@5.53.13)(typescript@5.9.3):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
chokidar: 4.0.3
fdir: 6.5.0(picomatch@4.0.3)
picocolors: 1.1.1
sade: 1.8.1
svelte: 5.53.7
svelte: 5.53.13
typescript: 5.9.3
transitivePeerDependencies:
- picomatch
svelte-eslint-parser@1.6.0(svelte@5.53.7):
svelte-eslint-parser@1.6.0(svelte@5.53.13):
dependencies:
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -24420,7 +24425,7 @@ snapshots:
postcss-selector-parser: 7.1.1
semver: 7.7.4
optionalDependencies:
svelte: 5.53.7
svelte: 5.53.13
svelte-floating-ui@1.5.8:
dependencies:
@@ -24433,7 +24438,7 @@ snapshots:
dependencies:
highlight.js: 11.11.1
svelte-i18n@4.0.1(svelte@5.53.7):
svelte-i18n@4.0.1(svelte@5.53.13):
dependencies:
cli-color: 2.0.4
deepmerge: 4.3.1
@@ -24441,10 +24446,10 @@ snapshots:
estree-walker: 2.0.2
intl-messageformat: 10.7.18
sade: 1.8.1
svelte: 5.53.7
svelte: 5.53.13
tiny-glob: 0.2.9
svelte-jsoneditor@3.11.0(svelte@5.53.7):
svelte-jsoneditor@3.11.0(svelte@5.53.13):
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/commands': 6.10.1
@@ -24471,42 +24476,42 @@ snapshots:
memoize-one: 6.0.0
natural-compare-lite: 1.4.0
sass: 1.97.1
svelte: 5.53.7
svelte-awesome: 3.3.5(svelte@5.53.7)
svelte: 5.53.13
svelte-awesome: 3.3.5(svelte@5.53.13)
svelte-select: 5.8.3
vanilla-picker: 2.12.3
svelte-maplibre@1.2.6(svelte@5.53.7):
svelte-maplibre@1.2.6(svelte@5.53.13):
dependencies:
d3-geo: 3.1.1
dequal: 2.0.3
just-compare: 2.3.0
maplibre-gl: 5.19.0
pmtiles: 3.2.1
svelte: 5.53.7
svelte: 5.53.13
svelte-parse-markup@0.1.5(svelte@5.53.7):
svelte-parse-markup@0.1.5(svelte@5.53.13):
dependencies:
svelte: 5.53.7
svelte: 5.53.13
svelte-persisted-store@0.12.0(svelte@5.53.7):
svelte-persisted-store@0.12.0(svelte@5.53.13):
dependencies:
svelte: 5.53.7
svelte: 5.53.13
svelte-select@5.8.3:
dependencies:
svelte-floating-ui: 1.5.8
svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7):
svelte-toolbelt@0.10.6(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13):
dependencies:
clsx: 2.1.1
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)
runed: 0.35.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.13)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
style-to-object: 1.0.14
svelte: 5.53.7
svelte: 5.53.13
transitivePeerDependencies:
- '@sveltejs/kit'
svelte@5.53.7:
svelte@5.53.13:
dependencies:
'@jridgewell/remapping': 2.3.5
'@jridgewell/sourcemap-codec': 1.5.5
@@ -24517,7 +24522,7 @@ snapshots:
aria-query: 5.3.1
axobject-query: 4.1.0
clsx: 2.1.1
devalue: 5.6.3
devalue: 5.6.4
esm-env: 1.2.2
esrap: 2.2.3
is-reference: 3.0.3
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.6.0",
"version": "2.6.1",
"description": "",
"author": "",
"private": true,
@@ -24,7 +24,7 @@
"typeorm": "typeorm",
"migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug",
"migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations create",
"migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run",
"migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert",
"schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'",
+1 -9
View File
@@ -169,6 +169,7 @@ export type AuthSharedLink = {
id: string;
expiresAt: Date | null;
userId: string;
albumId: string | null;
showExif: boolean;
allowUpload: boolean;
allowDownload: boolean;
@@ -357,15 +358,6 @@ export const columns = {
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
authApiKey: ['api_key.id', 'api_key.permissions'],
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
authSharedLink: [
'shared_link.id',
'shared_link.userId',
'shared_link.expiresAt',
'shared_link.showExif',
'shared_link.allowUpload',
'shared_link.allowDownload',
'shared_link.password',
],
user: userColumns,
userWithPrefix: userWithPrefixColumns,
userAdmin: [
@@ -173,6 +173,7 @@ order by
select
"shared_link"."id",
"shared_link"."userId",
"shared_link"."albumId",
"shared_link"."expiresAt",
"shared_link"."showExif",
"shared_link"."allowUpload",
@@ -211,6 +212,7 @@ where
select
"shared_link"."id",
"shared_link"."userId",
"shared_link"."albumId",
"shared_link"."expiresAt",
"shared_link"."showExif",
"shared_link"."allowUpload",
@@ -330,6 +330,7 @@ export class AlbumRepository {
await db
.insertInto('album_asset')
.values(assetIds.map((assetId) => ({ albumId, assetId })))
.onConflict((oc) => oc.doNothing())
.execute();
}
@@ -119,8 +119,12 @@ export class MetadataRepository {
}
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
// If exiftool assigns a field with ^= instead of =, empty values will be written too.
// Since exiftool-vendored doesn't support an option for this, we append the ^ to the name of the tag instead.
// https://exiftool.org/exiftool_pod.html#:~:text=is%20used%20to%20write%20an%20empty%20string
const tagsToWrite = Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key}^`, value]));
try {
await this.exiftool.write(path, tags);
await this.exiftool.write(path, tagsToWrite);
} catch (error) {
this.logger.warn(`Error writing exif data (${path}): ${error}`);
}
@@ -202,7 +202,14 @@ export class SharedLinkRepository {
.leftJoin('album', 'album.id', 'shared_link.albumId')
.where('album.deletedAt', 'is', null)
.select((eb) => [
...columns.authSharedLink,
'shared_link.id',
'shared_link.userId',
'shared_link.albumId',
'shared_link.expiresAt',
'shared_link.showExif',
'shared_link.allowUpload',
'shared_link.allowDownload',
'shared_link.password',
jsonObjectFrom(
eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'),
).as('user'),
@@ -0,0 +1,13 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`
DELETE FROM "shared_link_asset"
USING "shared_link"
WHERE "shared_link_asset"."sharedLinkId" = "shared_link"."id" AND "shared_link"."type" = 'ALBUM';
`.execute(db);
}
export async function down(): Promise<void> {
// noop
}
+2 -1
View File
@@ -64,8 +64,9 @@ export class UserTable {
@Column({ unique: true, nullable: true, default: null })
storageLabel!: string | null;
// TODO remove default, make nullable, and convert empty spaces to null
@Column({ default: '' })
name!: Generated<string>;
name!: string;
@Column({ type: 'bigint', nullable: true })
quotaSizeInBytes!: ColumnType<number> | null;
+12
View File
@@ -165,6 +165,12 @@ export class AlbumService extends BaseService {
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
if (auth.sharedLink) {
this.logger.deprecate(
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
);
}
const album = await this.findOrFail(id, { withAssets: false });
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] });
@@ -195,6 +201,12 @@ export class AlbumService extends BaseService {
}
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
if (auth.sharedLink) {
this.logger.deprecate(
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
);
}
const results: AlbumsAddAssetsResponseDto = {
success: false,
error: BulkIdErrorReason.DUPLICATE,
+9 -3
View File
@@ -2,7 +2,7 @@ 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 { Asset, AuthSharedLink } from 'src/database';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
@@ -152,7 +152,7 @@ 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.addToSharedLink(auth.sharedLink, asset.id);
}
await this.userRepository.updateUsage(auth.user.id, file.size);
@@ -326,6 +326,12 @@ export class AssetMediaService extends BaseService {
};
}
private async addToSharedLink(sharedLink: AuthSharedLink, assetId: string) {
await (sharedLink.albumId
? this.albumRepository.addAssetIds(sharedLink.albumId, [assetId])
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
}
private async handleUploadError(
error: any,
auth: AuthDto,
@@ -347,7 +353,7 @@ export class AssetMediaService extends BaseService {
}
if (auth.sharedLink) {
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]);
await this.addToSharedLink(auth.sharedLink, duplicateId);
}
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
+160 -222
View File
@@ -8,6 +8,7 @@ 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 { OAuthProfileFactory } from 'test/factories/oauth-profile.factory';
import { SessionFactory } from 'test/factories/session.factory';
import { UserFactory } from 'test/factories/user.factory';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
@@ -15,31 +16,7 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
const oauthResponse = ({
id,
email,
name,
profileImagePath,
}: {
id: string;
email: string;
name: string;
profileImagePath?: string;
}) => ({
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: id,
userEmail: email,
name,
profileImagePath,
isAdmin: false,
isOnboarded: false,
shouldChangePassword: false,
});
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
const email = 'test@immich.com';
const sub = 'my-auth-user-sub';
const loginDetails = {
isSecure: true,
clientIp: '127.0.0.1',
@@ -48,11 +25,9 @@ const loginDetails = {
appVersion: null,
};
const fixtures = {
login: {
email,
password: 'password',
},
const dto = {
email,
password: 'password',
};
describe(AuthService.name, () => {
@@ -63,7 +38,6 @@ describe(AuthService.name, () => {
({ sut, mocks } = newTestService(AuthService));
mocks.oauth.authorize.mockResolvedValue({ url: 'http://test', state: 'state', codeVerifier: 'codeVerifier' });
mocks.oauth.getProfile.mockResolvedValue({ sub, email });
mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint');
});
@@ -75,13 +49,13 @@ describe(AuthService.name, () => {
it('should throw an error if password login is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should check the user exists', async () => {
mocks.user.getByEmail.mockResolvedValue(void 0);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
});
@@ -89,7 +63,7 @@ describe(AuthService.name, () => {
it('should check the user has a password', async () => {
mocks.user.getByEmail.mockResolvedValue({} as UserAdmin);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
});
@@ -100,7 +74,7 @@ describe(AuthService.name, () => {
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({
await expect(sut.login(dto, loginDetails)).resolves.toEqual({
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: user.id,
userEmail: user.email,
@@ -624,6 +598,7 @@ describe(AuthService.name, () => {
it('should not allow auto registering', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
await expect(
sut.callback(
@@ -638,31 +613,31 @@ describe(AuthService.name, () => {
it('should link an existing user', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
});
it('should not link to a user with a different oauth sub', async () => {
const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' });
const user = UserFactory.create({ oauthId: 'existing-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.user.getByEmail.mockResolvedValueOnce(user);
mocks.user.getAdmin.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(user);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
await expect(
sut.callback(
@@ -677,35 +652,30 @@ describe(AuthService.name, () => {
});
it('should allow auto registering by default', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
expect(mocks.user.create).toHaveBeenCalledTimes(1);
});
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
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.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
mocks.oauth.getProfile.mockResolvedValue({ sub: 'sub' });
await expect(
sut.callback(
@@ -725,10 +695,9 @@ describe(AuthService.name, () => {
'app.immich:///oauth-callback?code=abc123',
]) {
it(`should use the mobile redirect override for a url of ${url}`, async () => {
const user = UserFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create());
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
@@ -743,135 +712,136 @@ describe(AuthService.name, () => {
}
it('should use the default quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(user);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should ignore an invalid storage quota', async () => {
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(UserFactory.create({ isAdmin: true }));
it('should infer name from given and family names', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(
OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Given Family' }));
});
it('should fallback to email when no username is provided', async () => {
const profile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: profile.email }));
});
it('should ignore an invalid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 'abc' }));
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should ignore a negative quota', async () => {
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.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: -5 }));
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should set quota for 0 quota', async () => {
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.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 0 }));
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email,
isAdmin: false,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: 0,
storageLabel: null,
});
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 0 }));
});
it('should use a valid storage quota', async () => {
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.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 5 }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email,
isAdmin: false,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: 5_368_709_120,
storageLabel: null,
});
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 5_368_709_120 }));
});
it('should sync the profile picture', async () => {
const fileId = newUuid();
const user = UserFactory.create({ oauthId: 'oauth-id' });
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue({
sub: user.oauthId,
email: user.email,
picture: pictureUrl,
});
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.crypto.randomUUID.mockReturnValue(fileId);
mocks.oauth.getProfilePicture.mockResolvedValue({
@@ -881,131 +851,96 @@ describe(AuthService.name, () => {
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.jpg`),
profileChangedAt: expect.any(Date),
});
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl);
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture);
});
it('should not sync the profile picture if the user already has one', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue({
sub: user.oauthId,
email: user.email,
picture: 'https://auth.immich.cloud/profiles/1.jpg',
});
mocks.oauth.getProfile.mockResolvedValue(
OAuthProfileFactory.create({
sub: user.oauthId,
email: user.email,
picture: 'https://auth.immich.cloud/profiles/1.jpg',
}),
);
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.oauth.getProfilePicture).not.toHaveBeenCalled();
});
it('should only allow "admin" and "user" for the role claim', async () => {
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.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'foo' }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: null,
storageLabel: null,
isAdmin: false,
});
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: false }));
});
it('should create an admin user if the role claim is set to admin', async () => {
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.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'admin' }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: null,
storageLabel: null,
isAdmin: true,
});
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
});
it('should accept a custom role claim', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue({
oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' },
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
});
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, my_role: 'admin' });
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ my_role: 'admin' }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: null,
storageLabel: null,
isAdmin: true,
});
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
});
});
@@ -1013,8 +948,10 @@ describe(AuthService.name, () => {
it('should link an account', async () => {
const user = UserFactory.create();
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.user.update.mockResolvedValue(user);
await sut.link(
@@ -1023,7 +960,7 @@ describe(AuthService.name, () => {
{},
);
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: sub });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
});
it('should not link an already linked oauth.sub', async () => {
@@ -1032,6 +969,7 @@ describe(AuthService.name, () => {
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
await expect(
+13 -5
View File
@@ -261,6 +261,11 @@ export class AuthService extends BaseService {
}
async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) {
const { oauth } = await this.getConfig({ withCache: false });
if (!oauth.enabled) {
throw new BadRequestException('OAuth is not enabled');
}
const expectedState = dto.state ?? this.getCookieOauthState(headers);
if (!expectedState?.length) {
throw new BadRequestException('OAuth state is missing');
@@ -271,7 +276,6 @@ export class AuthService extends BaseService {
throw new BadRequestException('OAuth code verifier is missing');
}
const { oauth } = await this.getConfig({ withCache: false });
const url = this.resolveRedirectUri(oauth, dto.url);
const profile = await this.oauthRepository.getProfile(oauth, url, expectedState, codeVerifier);
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
@@ -298,7 +302,8 @@ export class AuthService extends BaseService {
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
}
if (!profile.email) {
const email = profile.email;
if (!email) {
throw new BadRequestException('OAuth profile does not have an email address');
}
@@ -320,10 +325,13 @@ export class AuthService extends BaseService {
isValid: (value: unknown) => isString(value) && ['admin', 'user'].includes(value),
});
const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`;
user = await this.createUser({
name: userName,
email: profile.email,
name:
profile.name ||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
profile.preferred_username ||
email,
email,
oauthId: profile.sub,
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
storageLabel: storageLabel || null,
+1 -1
View File
@@ -467,7 +467,7 @@ export class MetadataService extends BaseService {
GPSLatitude: latitude,
GPSLongitude: longitude,
Rating: rating,
TagsList: tags?.length ? tags : undefined,
TagsList: tags,
},
_.isUndefined,
);
@@ -1,5 +1,6 @@
import { DateTime } from 'luxon';
import { SemVer } from 'semver';
import { defaults } from 'src/config';
import { serverVersion } from 'src/constants';
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service';
@@ -130,6 +131,32 @@ describe(VersionService.name, () => {
});
});
describe('onConfigUpdate', () => {
it('should queue a version check job when newVersionCheck is enabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: false } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
});
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} });
});
it('should not queue a version check job when newVersionCheck is disabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
newConfig: { ...defaults, newVersionCheck: { enabled: false } },
});
expect(mocks.job.queue).not.toHaveBeenCalled();
});
it('should not queue a version check job when newVersionCheck was already enabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
});
expect(mocks.job.queue).not.toHaveBeenCalled();
});
});
describe('onWebsocketConnection', () => {
it('should send on_server_version client event', async () => {
await sut.onWebsocketConnection({ userId: '42' });
+7
View File
@@ -55,6 +55,13 @@ export class VersionService extends BaseService {
return this.versionRepository.getAll();
}
@OnEvent({ name: 'ConfigUpdate' })
async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'ConfigUpdate'>) {
if (!oldConfig.newVersionCheck.enabled && newConfig.newVersionCheck.enabled) {
await this.handleQueueVersionCheck();
}
}
async handleQueueVersionCheck() {
await this.jobRepository.queue({ name: JobName.VersionCheck, data: {} });
}
@@ -0,0 +1,28 @@
import { OAuthProfile } from 'src/repositories/oauth.repository';
import { OAuthProfileLike } from 'test/factories/types';
import { newUuid } from 'test/small.factory';
export class OAuthProfileFactory {
private constructor(private value: OAuthProfile) {}
static create(dto: OAuthProfileLike = {}) {
return OAuthProfileFactory.from(dto).build();
}
static from(dto: OAuthProfileLike = {}) {
const sub = newUuid();
return new OAuthProfileFactory({
sub,
name: 'Name',
given_name: 'Given',
family_name: 'Family',
email: `oauth-${sub}@immich.cloud`,
email_verified: true,
...dto,
});
}
build() {
return { ...this.value };
}
}
+2
View File
@@ -1,4 +1,5 @@
import { Selectable } from 'kysely';
import { OAuthProfile } from 'src/repositories/oauth.repository';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table';
@@ -34,3 +35,4 @@ export type PartnerLike = Partial<Selectable<PartnerTable>>;
export type ActivityLike = Partial<Selectable<ActivityTable>>;
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
export type SessionLike = Partial<Selectable<SessionTable>>;
export type OAuthProfileLike = Partial<OAuthProfile>;
+1
View File
@@ -48,6 +48,7 @@ export const authStub = {
showExif: true,
allowDownload: true,
allowUpload: true,
albumId: null,
expiresAt: null,
password: null,
userId: '42',
+2 -2
View File
@@ -220,9 +220,9 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { result };
}
async newAlbum(dto: Insertable<AlbumTable>) {
async newAlbum(dto: Insertable<AlbumTable>, assetIds?: string[]) {
const album = mediumFactory.albumInsert(dto);
const result = await this.get(AlbumRepository).create(album, [], []);
const result = await this.get(AlbumRepository).create(album, assetIds ?? [], []);
return { album, result };
}
@@ -0,0 +1,75 @@
import { Kysely } from 'kysely';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { DB } from 'src/schema';
import { BaseService } from 'src/services/base.service';
import { newMediumService } from 'test/medium.factory';
import { newDate } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
const setup = () => {
const { ctx } = newMediumService(BaseService, {
database,
real: [],
mock: [LoggingRepository],
});
return { ctx, sut: ctx.get(MetadataRepository) };
};
beforeAll(async () => {
database = await getKyselyDB();
});
describe(MetadataRepository.name, () => {
describe('writeTags', () => {
it('should write an empty description', async () => {
const { sut } = setup();
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
const sidecarFile = join(dir, 'sidecar.xmp');
await sut.writeTags(sidecarFile, { Description: '' });
expect(readFileSync(sidecarFile).toString()).toEqual(expect.stringContaining('rdf:Description'));
});
it('should write an empty tags list', async () => {
const { sut } = setup();
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
const sidecarFile = join(dir, 'sidecar.xmp');
await sut.writeTags(sidecarFile, { TagsList: [] });
const fileContent = readFileSync(sidecarFile).toString();
expect(fileContent).toEqual(expect.stringContaining('digiKam:TagsList'));
expect(fileContent).toEqual(expect.stringContaining('<rdf:li/>'));
});
});
it('should write tags', async () => {
const { sut } = setup();
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
const sidecarFile = join(dir, 'sidecar.xmp');
await sut.writeTags(sidecarFile, {
Description: 'my-description',
ImageDescription: 'my-image-description',
DateTimeOriginal: newDate().toISOString(),
GPSLatitude: 42,
GPSLongitude: 69,
Rating: 3,
TagsList: ['tagA'],
});
const fileContent = readFileSync(sidecarFile).toString();
expect(fileContent).toEqual(expect.stringContaining('my-description'));
expect(fileContent).toEqual(expect.stringContaining('my-image-description'));
expect(fileContent).toEqual(expect.stringContaining('exif:DateTimeOriginal'));
expect(fileContent).toEqual(expect.stringContaining('<exif:GPSLatitude>42,0.0N</exif:GPSLatitude>'));
expect(fileContent).toEqual(expect.stringContaining('<exif:GPSLongitude>69,0.0E</exif:GPSLongitude>'));
expect(fileContent).toEqual(expect.stringContaining('<xmp:Rating>3</xmp:Rating>'));
expect(fileContent).toEqual(expect.stringContaining('tagA'));
});
});
@@ -1,12 +1,15 @@
import { Kysely } from 'kysely';
import { randomBytes } from 'node:crypto';
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
import { AssetMediaSize } from 'src/dtos/asset-media.dto';
import { AssetFileType } from 'src/enum';
import { AssetFileType, SharedLinkType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
@@ -22,7 +25,7 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(AssetMediaService, {
database: db || defaultDatabase,
real: [AccessRepository, AssetRepository, UserRepository],
real: [AccessRepository, AlbumRepository, AssetRepository, SharedLinkRepository, UserRepository],
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
});
};
@@ -44,7 +47,6 @@ describe(AssetService.name, () => {
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const auth = factory.auth({ user: { id: user.id } });
const file = mediumFactory.uploadFile();
await expect(
sut.uploadAsset(
@@ -56,7 +58,7 @@ describe(AssetService.name, () => {
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
},
file,
mediumFactory.uploadFile(),
),
).resolves.toEqual({
id: expect.any(String),
@@ -99,6 +101,168 @@ describe(AssetService.name, () => {
status: AssetMediaStatus.CREATED,
});
});
it('should add to a shared link', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Individual,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const file = mediumFactory.uploadFile();
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, file);
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
const assets = update!.assets;
expect(assets).toHaveLength(1);
expect(assets[0]).toMatchObject({ id: response.id });
});
it('should handle adding a duplicate asset to a shared link', async () => {
const { sut, ctx } = setup();
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Individual,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
assetIds: [asset.id],
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
const assets = update!.assets;
expect(assets).toHaveLength(1);
expect(assets[0]).toMatchObject({ id: response.id });
});
it('should add to an album shared link', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: user.id });
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Album,
albumId: album.id,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile());
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
const assets = [...result];
expect(assets).toHaveLength(1);
expect(assets[0]).toEqual(response.id);
});
it('should handle adding a duplicate asset to an album shared link', async () => {
const { sut, ctx } = setup();
const sharedLinkRepo = ctx.get(SharedLinkRepository);
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]);
// await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(50),
type: SharedLinkType.Album,
albumId: album.id,
description: 'Shared link description',
userId: user.id,
allowDownload: true,
allowUpload: true,
});
const auth = factory.auth({ user: { id: user.id }, sharedLink });
const uploadDto = {
deviceId: 'some-id',
deviceAssetId: 'some-id',
fileModifiedAt: new Date(),
fileCreatedAt: new Date(),
assetData: Buffer.from('some data'),
};
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
const assets = [...result];
expect(assets).toHaveLength(1);
expect(assets[0]).toEqual(response.id);
});
});
describe('viewThumbnail', () => {
@@ -47,15 +47,15 @@ describe(UserService.name, () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const user = mediumFactory.userInsert();
await expect(sut.createUser({ email: user.email })).resolves.toMatchObject({ email: user.email });
await expect(sut.createUser({ email: user.email })).rejects.toThrow('User exists');
await expect(sut.createUser({ name: 'Test', email: user.email })).resolves.toMatchObject({ email: user.email });
await expect(sut.createUser({ name: 'Test', email: user.email })).rejects.toThrow('User exists');
});
it('should not return password', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const dto = mediumFactory.userInsert({ password: 'password' });
const user = await sut.createUser({ email: dto.email, password: 'password' });
const user = await sut.createUser({ name: 'Test', email: dto.email, password: 'password' });
expect((user as any).password).toBeUndefined();
});
});
+11 -1
View File
@@ -63,12 +63,22 @@ const authSharedLinkFactory = (sharedLink: Partial<AuthSharedLink> = {}) => {
expiresAt = null,
userId = newUuid(),
showExif = true,
albumId = null,
allowUpload = false,
allowDownload = true,
password = null,
} = sharedLink;
return { id, expiresAt, userId, showExif, allowUpload, allowDownload, password };
return {
id,
albumId,
expiresAt,
userId,
showExif,
allowUpload,
allowDownload,
password,
};
};
const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.6.0",
"version": "2.6.1",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.64.0",
"@immich/ui": "^0.65.3",
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
@@ -100,7 +100,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.53.7",
"svelte": "5.53.13",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",
@@ -11,7 +11,7 @@
import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { sendTestEmailAdmin } from '@immich/sdk';
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
import { Button, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -142,6 +142,7 @@
<Button
size="small"
shape="round"
loading={isSending}
disabled={!configToEdit.notifications.smtp.enabled}
onclick={handleSendTestEmail}
>
@@ -151,9 +152,6 @@
{$t('admin.notification_email_sent_test_email_button')}
{/if}
</Button>
{#if isSending}
<LoadingSpinner />
{/if}
</div>
</div>
</SettingAccordion>
@@ -33,13 +33,11 @@
{#if isOwned}
<Textarea
bind:value={description}
class="outline-none border-b max-h-32 border-transparent pl-0 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
rows={1}
grow
shape="rectangle"
variant="ghost"
onfocusout={handleFocusOut}
placeholder={$t('add_a_description')}
data-testid="autogrow-textarea"
class="max-h-32"
{@attach fromAction(shortcut, () => ({
shortcut: { key: 'Enter', ctrl: true },
onShortcut: (e) => e.currentTarget.blur(),
@@ -3,59 +3,56 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo } from '@immich/sdk';
import { Textarea } from '@immich/ui';
import { t } from 'svelte-i18n';
import { tv } from 'tailwind-variants';
import { fromAction } from 'svelte/attachments';
interface Props {
type Props = {
id: string;
albumName: string;
isOwned: boolean;
onUpdate: (albumName: string) => void;
}
};
let { id, albumName = $bindable(), isOwned, onUpdate }: Props = $props();
let newAlbumName = $derived(albumName);
const handleUpdateName = async () => {
const handleUpdate = async () => {
newAlbumName = newAlbumName.replaceAll('\n', ' ').trim();
if (newAlbumName === albumName) {
return;
}
try {
const response = await updateAlbumInfo({
id,
updateAlbumDto: {
albumName: newAlbumName,
},
});
const response = await updateAlbumInfo({ id, updateAlbumDto: { albumName: newAlbumName } });
({ albumName } = response);
eventManager.emit('AlbumUpdate', response);
onUpdate(albumName);
} catch (error) {
handleError(error, $t('errors.unable_to_save_album'));
return;
}
};
const styles = tv({
base: 'w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90',
variants: {
isOwned: {
true: 'hover:border-gray-400',
false: 'hover:border-transparent',
},
},
});
const textClasses = 'text-2xl lg:text-6xl text-primary';
</script>
<input
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
onblur={handleUpdateName}
class={styles({ isOwned })}
type="text"
bind:value={newAlbumName}
disabled={!isOwned}
title={$t('edit_title')}
placeholder={$t('add_a_title')}
/>
<div class="mb-2">
{#if isOwned}
<Textarea
bind:value={newAlbumName}
variant="ghost"
title={$t('edit_title')}
onblur={handleUpdate}
placeholder={$t('add_a_title')}
class={textClasses}
{@attach fromAction(shortcut, () => ({
shortcut: { key: 'Enter' },
onShortcut: (event) => event.currentTarget.blur(),
}))}
/>
{:else}
<div class={textClasses}>{newAlbumName}</div>
{/if}
</div>
@@ -212,12 +212,12 @@
bottom: `${rootHeight - top}px`,
left: `${left}px`,
width: `${boundary.width}px`,
maxHeight: maxHeight(top - dropdownOffset),
maxHeight: maxHeight(boundary.top - dropdownOffset),
};
}
const viewportHeight = visualViewport?.height || rootHeight;
const availableHeight = modalBounds ? rootHeight - bottom : viewportHeight - boundary.bottom;
const viewportHeight = visualViewport?.height || window.innerHeight;
const availableHeight = viewportHeight - boundary.bottom;
return {
top: `${bottom}px`,
left: `${left}px`,
@@ -1,5 +1,6 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { cancelUploadRequests } from '$lib/utils';
import { getSupportedMediaTypes, type ServerMediaTypesResponseDto } from '@immich/sdk';
class UploadManager {
@@ -13,6 +14,7 @@ class UploadManager {
}
reset() {
cancelUploadRequests();
uploadAssetsStore.reset();
}
+1 -1
View File
@@ -273,7 +273,7 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
}
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_album'));
handleError(error, $t('errors.unable_to_delete_album'), { notify });
return false;
}
};
+25 -2
View File
@@ -78,17 +78,40 @@ export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
let unsubscribeId = 0;
const uploads: Record<number, () => void> = {};
const trackUpload = (unsubscribe: () => void) => {
const id = unsubscribeId++;
uploads[id] = unsubscribe;
return () => {
delete uploads[id];
};
};
export const cancelUploadRequests = () => {
for (const unsubscribe of Object.values(uploads)) {
unsubscribe();
}
};
export const uploadRequest = async <T>(options: UploadRequestOptions): Promise<{ data: T; status: number }> => {
const { onUploadProgress: onProgress, data, url } = options;
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const unsubscribe = trackUpload(() => xhr.abort());
xhr.addEventListener('error', (error) => {
unsubscribe();
reject(error);
});
xhr.addEventListener('error', (error) => reject(error));
xhr.addEventListener('load', () => {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300) {
unsubscribe();
resolve({ data: xhr.response as T, status: xhr.status });
} else {
unsubscribe();
reject(new ApiError(xhr.statusText, xhr.status, xhr.response));
}
});
+1 -1
View File
@@ -216,7 +216,7 @@ async function fileUploader({
uploadAssetsStore.track('success');
}
if (albumId) {
if (albumId && !authManager.isSharedLink) {
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') });
await addAssetsToAlbums([albumId], [responseData.id], { notify: false });
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') });
+5 -2
View File
@@ -23,7 +23,8 @@ export function standardizeError(error: unknown) {
return error instanceof Error ? error : new Error(String(error));
}
export function handleError(error: unknown, localizedMessage: string) {
export function handleError(error: unknown, localizedMessage: string, options?: { notify?: boolean }) {
const { notify = true } = options ?? {};
const standardizedError = standardizeError(error);
if (standardizedError.name === 'AbortError') {
return;
@@ -39,7 +40,9 @@ export function handleError(error: unknown, localizedMessage: string) {
const errorMessage = serverMessage || localizedMessage;
toastManager.danger(errorMessage);
if (notify) {
toastManager.danger(errorMessage);
}
return errorMessage;
} catch (error) {
@@ -287,7 +287,11 @@
}
};
const onAlbumAddAssets = async () => {
const onAlbumAddAssets = async ({ albumIds }: { albumIds: string[] }) => {
if (!albumIds.includes(album.id)) {
return;
}
await refreshAlbum();
timelineInteraction.clearMultiselect();
await setModeToView();